기록 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>
564 lines
33 KiB
Markdown
564 lines
33 KiB
Markdown
---
|
|
id: 74a7bacf-e5d8-4129-926a-c8cf5cacb8c9
|
|
kind: SETUP
|
|
slug: edge-nginx-and-host-dnat
|
|
title: 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다
|
|
topic: lab-environment-build
|
|
topicName: 실험대 환경 구성
|
|
project: virtualization
|
|
status: 게시 전
|
|
studio: "https://hyeonworks.com/studio/documents/74a7bacf-e5d8-4129-926a-c8cf5cacb8c9/edit"
|
|
pinnedVersions:
|
|
- name: nginx (엣지 게스트)
|
|
version: 1.22.1
|
|
- name: Debian GNU/Linux
|
|
version: 12 (bookworm)
|
|
- name: libvirt
|
|
version: 12.7.0
|
|
source:
|
|
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
|
|
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
|
|
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
|
|
- final/document.md#184-이-부의-출처와-범위
|
|
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
|
---
|
|
|
|
# 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다
|
|
|
|
엣지 게스트에 nginx 를 깔아 80 을 받게 하고 물리 호스트에 nftables DNAT 파일과 그것을 걸어 주는 systemd 유닛을 두는 절차다. 끝나면 밖에서 온 요청이 호스트를 지나 엣지 nginx 로, 거기서 Traefik 으로 닿는다. TLS 는 아직 없다.
|
|
|
|
## 관계
|
|
|
|
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
|
이 절차가 실행하는 결정이고, 왜 옮겼는지와 무엇을 감수했는지는 그 기록이 갖는다.
|
|
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
|
유닛의 `ExecStartPost` 가 뚫는 구멍이 왜 필요한지를 그 사건이 끝까지 따라간다.
|
|
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
|
|
앞 단계이고, 거기서 확인한 INTERNAL-IP 두 개가 여기서 upstream 주소가 된다.
|
|
- **DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다**
|
|
다음 단계이고, 여기서 `http` 로 둔 `X-Forwarded-Proto` 가 거기서 `https` 로 바뀐다.
|
|
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
|
① 의 `404` 를 성공으로 읽는 근거가 그 기준이다.
|
|
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
|
|
세우는 명령이 왜 재실행으로 검증되지 않은 채 남았는지를 그 기준이 가른다.
|
|
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 구멍이 필요한가**
|
|
이 절차의 유닛이 뚫는 구멍이 다른 백엔드에서도 필요한지는 아직 재지 않았다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## 읽기 전에 — 어디서 치는가
|
|
|
|
이 단계는 기계 둘을 오간다.
|
|
|
|
| 번호 | 무엇 | 어디서 |
|
|
|---|---|---|
|
|
| 1 | nginx 설치와 기본 사이트 끄기 | `[kc-lab-edge]` |
|
|
| 2 · 3 | 라우팅 설정과 링크 · 문법 검사와 reload | `[kc-lab-edge]` |
|
|
| 4 | DNAT 파일 | `[lab host]` — 여기만 엣지가 아니다 |
|
|
| 5 | systemd 유닛과 libvirt 구멍 | `[lab host]` |
|
|
| 판정 | 층별 확인 ①~④ | `[lab host]` 와 `[워크스테이션]` |
|
|
|
|
**4번과 5번만 호스트에서 치는 까닭이 둘이다.** DNAT 규칙의 첫 줄이 `iifname "tailscale0"` 인데 VM 에는 Tailscale 을 넣지 않기로 했으므로 엣지에는 그 인터페이스 자체가 없고, 넘기는 대상이 `192.168.122.10` 으로 가는 트래픽이라 넘기는 주체는 그 앞에 있는 호스트다. 엣지에 들어가서 치면 `tailscale0` 이 없어 규칙이 의미가 없다.
|
|
|
|
여는 파일은 셋이다 — nginx 라우팅 설정, nftables DNAT 파일, systemd 유닛. 셋 다 사람이 내용을 읽고 고쳐야 하는 파일이라 편집기로 연다. `nano` 는 저장이 `Ctrl+O` 다음 `Enter`, 나가기가 `Ctrl+X` 다. 설치와 링크와 문법 검사와 reload 는 운영자가 그대로 치는 명령을 쓴다.
|
|
|
|
## 이 단계가 세우는 것
|
|
|
|
가이드 03 의 「이 단계가 끝나면」은 두 줄이다.
|
|
|
|
> 밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다.
|
|
> 아직 TLS 는 없다.
|
|
|
|
같은 nginx 인데 사는 곳만 바꾼 배치다.
|
|
|
|
```text
|
|
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
|
|
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
|
|
```
|
|
|
|
파일 넷이 놓이는 곳과 저장소 원본이다.
|
|
|
|
| 어디에 | 무엇 | 저장소 원본 |
|
|
|---|---|---|
|
|
| `[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 와 Traefik 이 하는 일이 다르다.
|
|
|
|
| 어느 쪽 | 맡는 것 |
|
|
|---|---|
|
|
| 엣지 nginx (`kc-lab-edge`) | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` |
|
|
| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
|
|
|
|
이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심이고, 동시에 헤더 실험이 성립하는 근거가 된다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를 재려면 두 겹이 있어야 한다.
|
|
|
|
## 전제와 되돌리기
|
|
|
|
전제는 두 줄이다 — 앞 단계가 끝나 두 노드가 `Ready` 이고, 게스트 세 대가 떠 있다. 그리고 엣지에 nginx 는 아직 없다. cloud-init 이 까는 것은 `curl` 과 `nftables` 뿐이라 1번에서 직접 깐다.
|
|
|
|
**되돌리기가 적힌 것은 가이드 7편 가운데 이 편뿐이다.** 세우는 절차가 아니라 걷어낼 때만 본다.
|
|
|
|
| 무엇을 | 어디서 | 명령 |
|
|
|---|---|---|
|
|
| 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` 로 다시 걸면 복구된다. 네 줄이 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록은 없다(unknown).
|
|
|
|
**`.nft` 파일 맨 위의 `delete` 는 이것과 다르다.** 그 두 줄은 파일을 적용할 때마다 자동으로 도는 재적용 안전장치이고, 위 표의 삭제 명령은 사람이 끄는 버튼이다.
|
|
|
|
## 세우기 전에 먼저 본다
|
|
|
|
엣지 게스트에는 lab host 에서 들어간다. 여기부터 4번 앞까지가 게스트 셸이다.
|
|
|
|
```bash label="[lab host] 엣지 게스트에 들어간다"
|
|
ssh kc-lab-edge
|
|
```
|
|
|
|
**무엇을 확인하는가** — 엣지에 nginx 가 이미 깔려 있는지.
|
|
|
|
```bash label="[kc-lab-edge] 실행 파일이 있는지 본다"
|
|
which nginx
|
|
```
|
|
|
|
**어디를 봐야 하는가** — 아무것도 안 찍히면 미설치다. 2026-09-11 에 새로 만든 `kc-lab-edge` 에서는 이렇게 나왔다(observed).
|
|
|
|
```text
|
|
donghyeon@kc-lab-edge:~$ cd /etc/nginx/
|
|
-bash: cd: /etc/nginx/: No such file or directory
|
|
```
|
|
|
|
**이 결과가 의미하는 것** — `/etc/nginx` 디렉터리는 패키지가 만든다. 그것이 없다는 것은 경로를 잘못 짚었다는 뜻이 아니라 설치가 안 됐다는 뜻이다. 앞 단계의 cloud-init 목록에 nginx 가 없으므로 1번에서 깐다.
|
|
|
|
## 실행 절차
|
|
|
|
### 1. 엣지에 nginx 를 깔고 기본 사이트를 끈다
|
|
|
|
**목적** — 80 을 듣는 것이 우리 설정 하나가 되게 만든다.
|
|
|
|
```bash label="[kc-lab-edge] ① 패키지 목록을 갱신하고 nginx 를 깐다"
|
|
sudo apt update && sudo apt install -y nginx
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] ② 떴는지와 데비안 관례의 두 디렉터리가 있는지 본다"
|
|
systemctl status nginx --no-pager | head -5
|
|
ls /etc/nginx/
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] ③ 지금 무엇이 걸려 있는지 본다"
|
|
ls -l /etc/nginx/sites-enabled/
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] ④ 기본 링크를 지운다"
|
|
sudo rm /etc/nginx/sites-enabled/default
|
|
```
|
|
|
|
**예상 결과** — `Active:` 줄이 `active (running)` 이고 `ls` 결과에 `sites-available` 과 `sites-enabled` 가 둘 다 있다. ③ 에 `default` 한 줄이 보이고 ④ 뒤에는 목록이 빈다.
|
|
|
|
**왜 필요한가** — Debian 계열은 설치와 동시에 기동까지 하므로 이 시점에 nginx 는 이미 80 을 잡고 있고, 그것을 잡은 것이 `sites-enabled/default` 다. 2번에서 쓸 설정도 `listen 80 default_server` 라 그대로 두면 겹치고, 안 지우면 3번의 `nginx -t` 가 `a duplicate default server for 0.0.0.0:80` 으로 막는다. ③ 의 화살표를 보면 `default -> ../sites-available/default` 처럼 심볼릭 링크라, 지우는 것은 링크뿐이고 원본은 그대로 있다.
|
|
|
|
**문제가 생기면** — `nginx -t` 가 `duplicate default server` 를 내면 ④ 를 건너뛴 것이다. Arch 호스트에는 이 구조가 아예 없다 — `sites-available` 과 `sites-enabled` 는 Debian 패키징 관례이고 Arch 의 nginx 는 `nginx.conf` 에 직접 쓰거나 `conf.d/` 를 쓴다. 이 실험대는 운영과 맞추려고 Debian 게스트를 엣지로 두었으므로 여기서는 Debian 관례가 그대로 통한다.
|
|
|
|
### 2. 라우팅 설정을 쓴다
|
|
|
|
**목적** — 엣지가 받은 80 요청을 k3s 두 노드의 Traefik 으로 넘기게 한다.
|
|
|
|
```bash label="[kc-lab-edge] ① 설정 파일을 연다"
|
|
sudo nano /etc/nginx/sites-available/keycloak-lab
|
|
```
|
|
|
|
```nginx label="② keycloak-lab 에 쓸 내용"
|
|
# 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;
|
|
}
|
|
}
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] ③ 방금 쓴 파일이 제자리에 있는지 본다"
|
|
ls /etc/nginx/sites-available/
|
|
```
|
|
|
|
**예상 결과** — ③ 의 목록에 `keycloak-lab` 이 보인다. nginx 는 아직 이 파일을 읽지 않고, 읽게 만드는 것이 3번이다.
|
|
|
|
**왜 필요한가** — 이 단계에서는 80 만 세운다. 인증서가 없는데 `ssl_certificate` 줄을 미리 써 두면 설정 전체가 실패해서 80 블록까지 안 뜬다 — nginx 는 그 파일을 나중이 아니라 기동과 reload 시점에 읽는다. `X-Forwarded-Proto` 가 `http` 인 것도 지금이 그렇기 때문이고, 다음 단계에서 `https` 로 바뀐다. 실제로 80 으로 들어오는데 `https` 라고 적으면 Keycloak 이 리다이렉트 주소를 `https://` 로 만들고 로그인 도중에 끊긴다. upstream 이 둘인 것은 두 노드 모두 Traefik 이 뜨기 때문이고, nginx 는 기본 라운드로빈으로 번갈아 보내다가 한쪽이 죽으면 자동으로 뺀다.
|
|
|
|
:::warning
|
|
|
|
`sites-available` 은 복수형이다. `site-available` 로 치면 `nano` 가 군말 없이 빈 새 파일을 열고, 저장해도 nginx 는 그 파일을 영원히 안 읽고, `nginx -t` 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나는 가장 찾기 어려운 형태라, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.
|
|
|
|
:::
|
|
|
|
**문제가 생기면** — 어디다 썼는지는 이렇게 찾는다.
|
|
|
|
```bash label="[kc-lab-edge] 파일을 어디에 썼는지 찾는다"
|
|
sudo find /etc/nginx -name 'keycloak*'
|
|
```
|
|
|
|
### 3. 링크를 걸고 문법을 검사한 뒤 reload 한다
|
|
|
|
**목적** — 2번에서 쓴 파일을 nginx 가 읽는 목록에 올리고, 문법이 맞을 때만 적용한다.
|
|
|
|
```bash label="[kc-lab-edge] ① sites-enabled 에 링크를 건다"
|
|
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] ② 링크가 생겼고 default 가 없는지 본다"
|
|
ls -l /etc/nginx/sites-enabled/
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] ③ 문법을 보고 통과하면 reload 한다"
|
|
sudo nginx -t && sudo systemctl reload nginx
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] ④ reload 가 반영됐는지 프로세스 트리로 본다"
|
|
systemctl status nginx --no-pager | head -20
|
|
```
|
|
|
|
**예상 결과** — Debian 12 는 `[warn]` 한 줄을 늘 같이 내놓는다(observed).
|
|
|
|
```text
|
|
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
|
|
```
|
|
|
|
`syntax is ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 줄은 통과를 막지 않는다. 통과했으면 `&&` 뒤의 reload 가 이어서 돌고 `systemctl reload` 는 아무 말 없이 끝난다.
|
|
|
|
**왜 필요한가** — `&&` 로 이은 것은 문법이 깨진 설정으로 reload 하지 않으려는 것이다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍히고 reload 는 아예 안 돌아, 지금 돌고 있는 nginx 는 옛 설정 그대로 멀쩡하다. 경고와 오류를 여기서 갈라 두면 다음 단계에서 같은 `types_hash` 경고를 실패로 오독하지 않는다. ④ 의 프로세스 트리에서 워커 줄의 PID 를 눈에 담아 둔다 — reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용된 것이다. 다음 단계에서 인증서 갱신이 서빙까지 닿았는지를 똑같은 방법으로 판정한다.
|
|
|
|
**문제가 생기면** — `unknown directive "http2"` 가 나오면 nginx 판 번호를 본다. Debian 12 는 1.22 이고 그 지시어는 1.25.1 부터다. `systemctl reload` 가 실패하면 reload 말고 `sudo nginx -t` 를 먼저 친다.
|
|
|
|
### 4. 호스트에 DNAT 파일을 쓴다
|
|
|
|
**목적** — 밖에서 tailnet 으로 들어온 80 과 443 을 커널이 엣지 게스트로 넘기게 한다. 여기까지 하면 엣지 nginx 는 살아 있는데 아무도 거기로 안 보낸다.
|
|
|
|
여기부터는 물리 호스트다. 게스트 셸에서 먼저 나온다. 나오지 않고 치면 `tailscale0` 이 없는 기계에 DNAT 을 쓰게 된다.
|
|
|
|
```bash label="[kc-lab-edge] ⓪ lab host 로 나온다"
|
|
exit
|
|
```
|
|
|
|
```bash label="[lab host] ① 저장할 디렉터리를 먼저 만든다"
|
|
sudo mkdir -p /etc/nftables.d
|
|
```
|
|
|
|
```bash label="[lab host] ② DNAT 파일을 연다"
|
|
sudo nano /etc/nftables.d/lab-edge-dnat.nft
|
|
```
|
|
|
|
```text label="③ 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
|
|
}
|
|
|
|
}
|
|
```
|
|
|
|
**예상 결과** — 파일만 생긴다. 이 파일은 5번의 유닛이 읽을 때 비로소 커널에 들어간다.
|
|
|
|
**왜 필요한가** — 디렉터리가 없으면 `nano` 가 저장할 곳을 못 찾으므로 ① 을 먼저 친다. `table ip lab_edge` 와 `delete table ip lab_edge` 두 줄이 파일 맨 앞에 있는 것은 없는 테이블을 지우면 에러가 나기 때문이다. 한 번 만들고 지우면 같은 파일을 몇 번 적용해도 안전해진다. tailnet 주소 `100.83.212.4` 의 80 과 443 을 받는 것은 여전히 물리 호스트이고, 그 트래픽을 엣지로 넘기는 일이 물리 호스트가 실험대를 위해 하는 일의 전부다 — 이 규칙 하나와 게스트를 만들 때 넣은 DHCP 예약 세 줄이고, 둘 다 한 번 쓰고 다시 안 건드린다.
|
|
|
|
`prerouting` 체인 뒤에 빈 줄이 하나 남아 있다. 처음 쓴 파일에는 거기에 `priority filter - 10` 으로 먼저 도는 `forward` 체인이 있었고 `ct state new accept` 를 넣어 두었는데, 밖에서 오는 요청은 그래도 통과하지 못했다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니라서, 같은 훅에 붙은 뒤 체인이 그대로 `reject` 한다. 그래서 그 체인을 지웠고, 구멍은 5번에서 libvirt 체인 안에 뚫는다.
|
|
|
|
:::warning
|
|
|
|
`443` 을 `433` 으로 치지 않는다. `433` 도 유효한 포트라 nft 가 군말 없이 받는다. 80 은 멀쩡히 넘어가므로 이 단계와 층별 확인은 다 통과하고, 다음 단계에서 HTTPS 만 안 되는 형태로 뒤늦게 터진다. 이 실험대에서 실제로 나왔던 오타다.
|
|
|
|
:::
|
|
|
|
**문제가 생기면** — 80 은 되는데 HTTPS 만 안 되면 포트 숫자부터 본다.
|
|
|
|
```bash label="[lab host] 포트 숫자를 눈으로 확인한다"
|
|
grep dport /etc/nftables.d/lab-edge-dnat.nft
|
|
```
|
|
|
|
### 5. 호스트에 systemd 유닛을 쓰고 켠다
|
|
|
|
**목적** — 부팅할 때마다 DNAT 파일을 적용하고, libvirt 의 `reject` 앞에 구멍을 뚫는다.
|
|
|
|
```bash label="[lab host] ① 유닛 파일을 연다. 경로를 system 까지 끝까지 친다"
|
|
sudo nano /etc/systemd/system/lab-edge-dnat.service
|
|
```
|
|
|
|
```ini label="② lab-edge-dnat.service 에 쓸 내용"
|
|
# file: /etc/systemd/system/lab-edge-dnat.service
|
|
[Unit]
|
|
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
|
|
After=network-online.target libvirtd.service
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
RemainAfterExit=yes
|
|
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
|
|
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
|
|
ExecStop=/usr/sbin/nft delete table ip lab_edge
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
```bash label="[lab host] ③ 두 파일이 제자리에 있는지 본다"
|
|
ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service
|
|
```
|
|
|
|
```bash label="[lab host] ④ 유닛 목록을 다시 읽는다"
|
|
sudo systemctl daemon-reload
|
|
```
|
|
|
|
```bash label="[lab host] ⑤ 지금 켜고 부팅에도 걸어 둔다"
|
|
sudo systemctl enable --now lab-edge-dnat.service
|
|
```
|
|
|
|
**예상 결과** — ③ 에서 두 줄이 다 나와야 한다. 한 줄이라도 `No such file` 이면 다음 명령은 무조건 실패한다. `enable --now` 가 심볼릭 링크를 만들고 유닛을 한 번 돌리는데, `Type=oneshot` 과 `RemainAfterExit=yes` 라 프로세스는 안 남고 상태만 `active` 로 남는다.
|
|
|
|
**왜 필요한가** — 규칙의 알맹이는 두 줄이고 둘이 사는 곳이 다르다.
|
|
|
|
| 하는 일 | 어디에 |
|
|
|---|---|
|
|
| tailnet 으로 들어온 80 과 443 을 `192.168.122.10` 으로 넘긴다 | 우리 테이블 `lab_edge` (`.nft` 파일) |
|
|
| 그 주소로 가는 새 연결을 통과시킨다 | libvirt 테이블 `libvirt_network` 의 `guest_input` 체인 (유닛의 `ExecStartPost`) |
|
|
|
|
libvirt 는 게스트 대역으로 새로 들어오는 연결을 거절한다. 자기 테이블의 `guest_input` 체인이 `established,related` 만 받고 나머지를 `reject` 로 끝내기 때문인데, 우리 테이블에 아무리 먼저 `accept` 를 놔도 소용이 없다. 그래서 구멍은 libvirt 체인 맨 앞에 뚫고, `insert` 가 맨 앞에 넣는다는 점이 핵심이다 — `add` 는 맨 뒤라 `reject` 뒤가 되어 의미가 없다. 이 규칙은 libvirt 가 네트워크를 다시 세우면(호스트 재부팅, `virsh net-start`, libvirtd 재시작) 날아가므로 유닛에 붙여 둔다.
|
|
|
|
`ExecStartPost` 앞의 `-` 는 그 명령이 실패해도 유닛을 실패로 보지 않는다는 뜻이다. `libvirt_network` 테이블은 가상 네트워크가 떠 있어야 존재하는데 부팅 순서에 따라 이 유닛이 먼저 돌 수 있고, `-` 가 없으면 구멍이 안 들어가면서 DNAT 까지 같이 안 실린다. `-` 를 두고 감수한 것이 그 반대쪽이다 — DNAT 만 실리고 구멍이 빠진 상태에서도 유닛은 `active` 이고 아무 오류도 안 남는다. 그래서 `systemctl is-active` 가 `active` 라는 것은 DNAT 이 실렸다는 뜻이지 구멍이 뚫렸다는 뜻이 아니고, 그 상태는 호스트 안에서는 되는데 밖에서만 안 되는 형태로 아래 확인 ③ 에서야 드러난다. 이 실험대가 그 상태를 일부러 만들어 확인해 보지는 않았다.
|
|
|
|
손으로 한 번 넣어 볼 때와 날아갔을 때 다시 넣을 때는 이렇다.
|
|
|
|
```bash label="[lab host] 손으로 구멍을 한 번 넣어 본다"
|
|
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
|
|
```
|
|
|
|
```bash label="[lab host] 새 규칙이 reject 위에 있는지 본다"
|
|
sudo nft -a list chain ip libvirt_network guest_input
|
|
```
|
|
|
|
```bash label="[lab host] 날아갔으면 유닛을 다시 돌려 넣는다"
|
|
sudo systemctl restart lab-edge-dnat.service
|
|
```
|
|
|
|
```bash label="[lab host] 우리 테이블이 들어갔는지 본다"
|
|
sudo nft list table ip lab_edge
|
|
```
|
|
|
|
마지막 명령에서는 세 가지를 본다. `dnat to 192.168.122.10` 한 줄이 있는가, 포트가 `80, 443` 인가, 그리고 `masquerade` 나 `snat` 이 없는가.
|
|
|
|
:::warning
|
|
|
|
SNAT 을 걸지 않는다. 게스트의 기본 게이트웨이가 호스트라 응답은 어차피 여기로 돌아오고 conntrack 이 되돌린다. masquerade 를 붙이면 출발지가 덮여서 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 이 실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
|
|
|
|
:::
|
|
|
|
**문제가 생기면** — `Unit lab-edge-dnat.service does not exist` 가 되풀이되면 두 곳을 본다. 경로를 `/etc/systemd/` 까지만 쳤는지, 그리고 `daemon-reload` 를 했는지. `nano` 는 없는 파일이면 말없이 새로 만들고, systemd 는 한 단계 위를 유닛 디렉터리로 읽지 않는다. 중괄호에 따옴표를 빼면 셸이 `80 443` 두 낱말로 펼쳐 `Error: syntax error, unexpected ct` 가 나는데, 규칙은 안 들어갔고 에러만 보고 넘기기 쉽다.
|
|
|
|
## 구성 값
|
|
|
|
| 무엇 | 값 |
|
|
|---|---|
|
|
| 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` |
|
|
|
|
PREROUTING nat 은 라우팅 결정보다 먼저 도므로 이 규칙이 호스트 자신의 443 소켓보다 우선한다. 그래서 물리 호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 가고, 전환이 원자적이며 되돌리기도 한 줄이 된다.
|
|
|
|
## 끝났는지 판정한다
|
|
|
|
한 번에 밖에서 치지 말고 가까운 층부터 본다. 네 명령이 각각 다른 층을 건너뛰므로 어디서 끊겼는지가 바로 나온다.
|
|
|
|
| # | 무엇을 건너뛰나 | 실측 |
|
|
|---|---|---|
|
|
| ① `192.168.122.11` | nginx 를 건너뛴다 | `404` |
|
|
| ② `192.168.122.10` | DNAT 을 건너뛴다 | `301` |
|
|
| ③ 도메인 | 밖에서 | `301 https://auth.hyeonworks.com/` |
|
|
| ④ 도메인 · TLS 이후 | — | `200` |
|
|
|
|
처음 볼 때는 응답을 눈으로 읽는 형태로 치고, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할 때만 값만 뽑는 형태로 바꾼다.
|
|
|
|
### 확인 ① Traefik 이 듣고 있나
|
|
|
|
**무엇을 확인하는가** — 게스트의 80 을 누가 듣고 있는지. nginx 를 건너뛴다.
|
|
|
|
```bash label="[lab host] ① nginx 를 건너뛰고 Traefik 에 직접"
|
|
curl -I http://192.168.122.11
|
|
```
|
|
|
|
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나, 그리고 그 앞에 아무 오류도 없이 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가 없는 것이라 상태 코드를 볼 일도 없다.
|
|
|
|
**이 결과가 의미하는 것** — `404` 가 성공 신호다. 게스트의 80 을 Traefik 이 듣고 있고, 요청을 받아 「매칭되는 Ingress 규칙이 없다」고 답했다. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 앞 단계로 돌아간다.
|
|
|
|
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다.
|
|
|
|
```bash label="[lab host] 두 노드를 나란히 비교한다"
|
|
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` 에서 잰 값이 `404` 였다(observed). 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은 결과가 나온다. 한쪽만 다르면 upstream 둘 중 하나가 죽은 것이고, 그 상태에서는 요청의 절반만 실패해서 「가끔 안 된다」로 보인다.
|
|
|
|
### 확인 ② 엣지 nginx 가 직접 응답하나
|
|
|
|
**무엇을 확인하는가** — 엣지 안의 설정과 기동이 맞는지. DNAT 을 건너뛴다.
|
|
|
|
```bash label="[lab host] ② DNAT 을 건너뛰고 엣지 nginx 에 직접"
|
|
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
|
|
```
|
|
|
|
**어디를 봐야 하는가** — `301` 과 `Location` 이 나오는가.
|
|
|
|
**이 결과가 의미하는 것** — 여기서 막히면 문제는 엣지 안이다. 통과하는데 아래 ③ 이 안 되면 문제는 DNAT 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
|
|
|
|
### 확인 ③ 밖에서, 즉 DNAT 을 거쳐 닿나
|
|
|
|
**무엇을 확인하는가** — DNS 와 DNAT 과 80 리스너가 전부 살아 있는지.
|
|
|
|
```bash label="[워크스테이션] ③ 밖에서 도메인으로"
|
|
curl -I http://auth.hyeonworks.com
|
|
```
|
|
|
|
**어디를 봐야 하는가** — 상태 줄과 `Location:` 헤더 한 줄. `Location` 이 `https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. 설정에 `$host` 대신 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
|
|
|
|
**이 결과가 의미하는 것** — `301` 이 나왔다는 것은 바깥 요청이 DNAT 을 거쳐 엣지 nginx 까지 닿았다는 뜻이다. ① 은 Traefik 에 직접, ② 는 엣지에 직접 친 것이라 DNAT 을 안 거쳤다. 응답이 아예 없으면 nginx 가 안 떴거나 80 이 막혔다. 값을 반복해서 잴 때의 형태와 이 실험대의 실측은 이렇다(observed).
|
|
|
|
```bash label="[워크스테이션] 반복해서 잴 때의 형태"
|
|
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
|
|
```
|
|
|
|
```text
|
|
301 https://auth.hyeonworks.com/
|
|
```
|
|
|
|
### 확인 ④ 끝까지 닿나
|
|
|
|
**무엇을 확인하는가** — nginx 에서 Traefik 을 지나 파드까지 2홉이 다 이어졌는지.
|
|
|
|
```bash label="[워크스테이션] ④ TLS 를 얹은 다음 단계에서 통과한다"
|
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
|
```
|
|
|
|
```text
|
|
200
|
|
```
|
|
|
|
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값을 뽑는 형태를 바로 쓰는 까닭은, 이 `200` 이 다음 두 단계에서 매번 같은 명령으로 다시 잴 기준값이기 때문이다.
|
|
|
|
**이 결과가 의미하는 것** — `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것이고, `curl: (60)` 같은 인증서 오류는 아직 다음 단계를 안 했다는 뜻이다. 여기까지는 TLS 가 없으므로 ④ 는 다음 단계에서 통과한다. 처음 보는 오류라 헤더가 필요하면 읽는 형태로 바꾼다.
|
|
|
|
```bash label="[워크스테이션] 처음 보는 오류는 헤더까지 읽는다"
|
|
curl -I https://auth.hyeonworks.com/realms/master
|
|
```
|
|
|
|
### 확인 ⑤ 로그에 무엇이 남았나
|
|
|
|
**무엇을 확인하는가** — nginx 가 어느 upstream 에서 무엇으로 실패했는지.
|
|
|
|
```bash label="[kc-lab-edge] 최근 에러만 · 지금 벌어지는 것"
|
|
journalctl -u nginx -p err -n 5 # 최근 에러만
|
|
journalctl -u nginx -f # 지금 벌어지는 것
|
|
```
|
|
|
|
**어디를 봐야 하는가** — 각 줄의 괄호 안 errno 와 그 뒤의 upstream 주소, 그리고 타임스탬프. 방금 친 요청 시각과 안 맞으면 지금 보고 있는 것은 옛 사고다.
|
|
|
|
**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는 정상이고 문제는 더 위에 있다. 둘째 명령은 띄워 놓은 채로 다른 창에서 요청을 치는 용도이고, 끝내려면 Ctrl+C 를 누른다. upstream 이 둘이라 한쪽이 죽으면 nginx 가 자동으로 빼는데, 그 동작이 세 줄로 남는다(observed).
|
|
|
|
```text
|
|
connect() failed (113: No route to host) ← 호스트에 못 닿는다
|
|
connect() failed (111: Connection refused) ← 포트에 아무도 없다
|
|
no live upstreams ← 둘 다 죽었다고 판단
|
|
```
|
|
|
|
113 과 111 은 대응이 다르다 — 113 은 네트워크고 111 은 프로세스다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다(observed).
|
|
|
|
:::note
|
|
|
|
nginx 에러 로그는 2048바이트에서 잘린다. 긴 URL 이 단어 중간에서 끊겨 보이면 이 제한에 걸린 것이고, 되찾으려면 access 로그를 본다. 이 실험대에서 502 원인이 error 로그에 있었는데 잘려 있었고 access 로그에는 3492자로 온전히 남아 있었다.
|
|
|
|
:::
|
|
|
|
```bash label="[kc-lab-edge] 잘리지 않은 한 줄을 access 로그에서 본다"
|
|
grep oauth2/callback /var/log/nginx/access.log | tail -1
|
|
```
|
|
|
|
```bash label="[kc-lab-edge] 그 줄의 길이를 센다"
|
|
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
|
|
```
|
|
|
|
## 통과 조건을 한 번에 다시 본다
|
|
|
|
| 무엇 | 명령 | 통과 |
|
|
|---|---|---|
|
|
| nginx 문법 | `sudo nginx -t` | `syntax is ok` · `test is successful` |
|
|
| 링크 | `ls -l /etc/nginx/sites-enabled/` | `keycloak-lab` 이 있고 `default` 가 없다 |
|
|
| 유닛 | `systemctl is-active lab-edge-dnat.service` | `active` |
|
|
| 우리 테이블 | `sudo nft list table ip lab_edge` | `dnat to 192.168.122.10` · 포트 `80, 443` · SNAT 없음 |
|
|
| 층 ① | `curl -I http://192.168.122.11` | `404` |
|
|
| 층 ② | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10` | `301` |
|
|
| 층 ③ | `curl -I http://auth.hyeonworks.com` | `301` · `Location` 이 원래 호스트명 |
|
|
|
|
층 ④ 는 다음 단계가 인증서를 얹은 뒤에 통과한다.
|
|
|
|
## 막히면
|
|
|
|
| 증상 | 어디서 끊겼나 | 확인 |
|
|
|---|---|---|
|
|
| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` |
|
|
| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 |
|
|
| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` |
|
|
| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | 다음 단계 · 위 확인 ⑤ |
|
|
| `/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` |
|
|
| `systemctl reload` 가 실패한다 | 설정이 `nginx -t` 를 못 통과했다 | reload 말고 `sudo nginx -t` 를 먼저 |
|
|
| `cp: cannot stat 'deploy/...'` | 엣지에서 쳤거나 lab host 에 저장소가 없다 | `ls ~/workspace` |
|
|
| `Unit lab-edge-dnat.service does not exist` | 유닛이 없거나 `/etc/systemd/` 에 썼거나 `daemon-reload` 를 안 했다 | `find /etc/systemd -name 'lab-edge-dnat*'` |
|
|
| 80 은 되는데 HTTPS 만 안 된다 | `.nft` 의 포트가 `433` 오타 | `grep dport /etc/nftables.d/lab-edge-dnat.nft` |
|
|
| 호스트 안에서는 404 인데 밖에서만 connection refused | libvirt `guest_input` 의 `reject`. 구멍이 빠졌거나 날아갔다 | `sudo nft -a list chain ip libvirt_network guest_input` — `reject` 줄의 카운터가 올라가면 여기다 |
|
|
| `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` 와 유닛 파일의 내용은 저장소 원본과 같다.
|
|
- (inferred) 이 이동으로 L7 홉 수가 2홉 그대로라는 판정이 이 배치의 전제다. 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약이 그대로 성립한다.
|
|
- (unknown) `curl -I http://192.168.122.11` 의 전체 출력은 캡처해 두지 않았다. 가이드도 봐야 할 줄만 적었다.
|
|
- (unknown) `systemctl status nginx` 두 번과 `ls -l /etc/nginx/sites-enabled/`, 파일 찾기, 체인 조회, `journalctl -u nginx`, access 로그 두 줄은 가이드가 적어 둔 명령이고 출력이 남아 있지 않다.
|
|
- (unknown) libvirt 의 `firewall_backend` 가 iptables 일 때도 `guest_input` 구멍이 필요한지는 재지 않았다. 이 호스트는 nftables 백엔드다.
|
|
- (unknown) 되돌리기 네 줄은 가이드 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록이 없다.
|
|
- (unknown) 세우는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.
|
|
|
|
<!-- body:end -->
|