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

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

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

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

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

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

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

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

54 KiB

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
74a7bacf-e5d8-4129-926a-c8cf5cacb8c9 SETUP edge-nginx-and-host-dnat 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다 lab-environment-build 실험대 환경 구성 virtualization 게시 전 https://hyeonworks.com/studio/documents/74a7bacf-e5d8-4129-926a-c8cf5cacb8c9/edit
name version
nginx (엣지 게스트) 1.22.1
name version
Debian GNU/Linux 12 (bookworm)
name version
libvirt 12.7.0
final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
final/document.md#184-이-부의-출처와-범위
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 구멍이 필요한가 이 절차의 유닛이 뚫는 구멍이 다른 백엔드에서도 필요한지는 아직 재지 않았다.

본문

읽기 전에 — 어디서 치는가

이 단계는 기계 둘을 오간다.

번호 무엇 어디서
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 유닛, CoreDNS 매니페스트. 셋 다 사람이 내용을 읽고 고쳐야 하는 파일이라 편집기로 연다. nano 는 저장이 Ctrl+O 다음 Enter, 나가기가 Ctrl+X 다. 설치와 링크와 문법 검사와 reload 는 운영자가 그대로 치는 명령을 쓴다.

이 단계가 세우는 것

가이드 03 의 「이 단계가 끝나면」은 두 줄이다.

밖에서 보낸 요청이 호스트 DNAT → 엣지 nginx → Traefik → 파드로 닿는다. 아직 TLS 는 없다.

같은 nginx 인데 사는 곳만 바꾼 배치다.

전:  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 이 까는 것은 curlnftables 뿐이라 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
클러스터 안에 더한 이름을 걷어낸다 [lab host] kubectl -n kube-system delete configmap coredns-custom 하고 kubectl -n kube-system delete pod -l k8s-app=kube-dns

sites-available 의 원본은 남으므로 ln -sf 로 다시 걸면 복구된다. 앞 네 줄이 가이드 원문 그대로이고, 마지막 줄은 이 기록이 더한 것이다 — CoreDNS 매니페스트를 이 기록이 처음 넣었는데 걷어내는 명령이 빠져 있었다. 다섯 줄 어느 것도 실제로 쳐서 걷어내 본 기록은 없다(unknown). 마지막 줄을 치면 클러스터 안에서 auth.hyeonworks.com 이 다시 안 풀리므로, 그 이름에 의존하는 실험을 끝낸 뒤에 친다.

.nft 파일 맨 위의 delete 는 이것과 다르다. 그 두 줄은 파일을 적용할 때마다 자동으로 도는 재적용 안전장치이고, 위 표의 삭제 명령은 사람이 끄는 버튼이다.

세우기 전에 먼저 본다

엣지 게스트에는 lab host 에서 들어간다. 여기부터 4번 앞까지가 게스트 셸이다.

ssh kc-lab-edge

무엇을 확인하는가 — 엣지에 nginx 가 이미 깔려 있는지.

ls /etc/nginx/

어디를 봐야 하는가No such file or directory 가 나오면 미설치다. 2026-09-11 에 새로 만든 kc-lab-edge 에서는 이렇게 나왔다(observed).

donghyeon@kc-lab-edge:~$ cd /etc/nginx/
-bash: cd: /etc/nginx/: No such file or directory

이 결과가 의미하는 것/etc/nginx 디렉터리는 패키지가 만든다. 그것이 없다는 것은 경로를 잘못 짚었다는 뜻이 아니라 설치가 안 됐다는 뜻이다. 앞 단계의 cloud-init 목록에 nginx 가 없으므로 1번에서 깐다.

여기에 which nginx 를 쓰지 않는다. 깔려 있을 때도 똑같이 아무것도 안 찍힌다 — 2026-09-17 에 nginx 가 돌고 있는 엣지에서 직접 쳐서 확인했다(observed).

which nginx   -> (아무것도 안 찍고 exit 1)
PATH          -> /usr/local/bin:/usr/bin:/bin:/usr/games
sudo nginx -v -> nginx version: nginx/1.22.1

실행 파일은 /usr/sbin/nginx 인데 데비안의 일반 사용자 PATH/usr/sbin 이 없다. 그래서 이 명령의 빈손은 「안 깔렸다」가 아니라 「이 PATH 에서는 안 보인다」이고, 깔린 경우와 안 깔린 경우의 출력이 같아 판정에 쓸 수 없다. 판 번호를 볼 때도 nginx -v 가 아니라 sudo nginx -v 다.

실행 절차

1. 엣지에 nginx 를 깔고 기본 사이트를 끈다

목적 — 80 을 듣는 것이 우리 설정 하나가 되게 만든다.

sudo apt update && sudo apt install -y nginx
systemctl status nginx --no-pager | head -5
ls /etc/nginx/
ls -l /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default

예상 결과Active: 줄이 active (running) 이고 ls 결과에 sites-availablesites-enabled 가 둘 다 있다. ③ 에 default 한 줄이 보이고 ④ 뒤에는 목록이 빈다.

왜 필요한가 — Debian 계열은 설치와 동시에 기동까지 하므로 이 시점에 nginx 는 이미 80 을 잡고 있고, 그것을 잡은 것이 sites-enabled/default 다. 2번에서 쓸 설정도 listen 80 default_server 라 그대로 두면 겹치고, 안 지우면 3번의 nginx -ta duplicate default server for 0.0.0.0:80 으로 막는다. ③ 의 화살표를 보면 default -> ../sites-available/default 처럼 심볼릭 링크라, 지우는 것은 링크뿐이고 원본은 그대로 있다.

문제가 생기면nginx -tduplicate default server 를 내면 ④ 를 건너뛴 것이다. Arch 호스트에는 이 구조가 아예 없다 — sites-availablesites-enabled 는 Debian 패키징 관례이고 Arch 의 nginx 는 nginx.conf 에 직접 쓰거나 conf.d/ 를 쓴다. 이 실험대는 운영과 맞추려고 Debian 게스트를 엣지로 두었으므로 여기서는 Debian 관례가 그대로 통한다.

2. 라우팅 설정을 쓴다

목적 — 엣지가 받은 80 요청을 k3s 두 노드의 Traefik 으로 넘기게 한다.

sudo nano /etc/nginx/sites-available/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;
	}
}
ls /etc/nginx/sites-available/

예상 결과 — ③ 의 목록에 keycloak-lab 이 보인다. nginx 는 아직 이 파일을 읽지 않고, 읽게 만드는 것이 3번이다.

왜 필요한가 — 이 단계에서는 80 만 세운다. 인증서가 없는데 ssl_certificate 줄을 미리 써 두면 설정 전체가 실패해서 80 블록까지 안 뜬다 — nginx 는 그 파일을 나중이 아니라 기동과 reload 시점에 읽는다. X-Forwarded-Protohttp 인 것도 지금이 그렇기 때문이고, 다음 단계에서 https 로 바뀐다. 실제로 80 으로 들어오는데 https 라고 적으면 Keycloak 이 리다이렉트 주소를 https:// 로 만들고 로그인 도중에 끊긴다. upstream 이 둘인 것은 두 노드 모두 Traefik 이 뜨기 때문이고, nginx 는 기본 라운드로빈으로 번갈아 보내다가 한쪽이 죽으면 자동으로 뺀다.

:::warning

sites-available 은 복수형이다. site-available 로 치면 nano 가 군말 없이 빈 새 파일을 열고, 저장해도 nginx 는 그 파일을 영원히 안 읽고, nginx -t 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나는 가장 찾기 어려운 형태라, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.

:::

문제가 생기면 — 어디다 썼는지는 이렇게 찾는다.

sudo find /etc/nginx -name 'keycloak*'

3. 링크를 걸고 문법을 검사한 뒤 reload 한다

목적 — 2번에서 쓴 파일을 nginx 가 읽는 목록에 올리고, 문법이 맞을 때만 적용한다.

sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
ls -l /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
systemctl status nginx --no-pager | head -20

예상 결과 — 2026-09-11 의 엣지에서는 [warn] 한 줄이 앞에 붙어 세 줄이었다(observed).

nginx: [warn] could not build optimal types_hash, you should increase either
types_hash_max_size: 1024 or types_hash_bucket_size: 64
nginx: configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

[warn] 이 늘 나오지는 않는다. 2026-09-17 에 같은 Debian 12 · 같은 nginx/1.22.1 엣지를 새로 세우고 세 번 쳤더니 한 번도 안 나왔다(observed). 두 줄뿐이었다.

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

판정은 syntax is oktest is successful 두 마디로만 한다. [warn] 줄은 나오면 무시하고 안 나오면 없는 대로 둔다 — 없다고 설정이 덜 읽힌 것이 아니다. 통과했으면 && 뒤의 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 을 쓰게 된다.

exit
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
    }

}

예상 결과 — 파일만 생긴다. 이 파일은 5번의 유닛이 읽을 때 비로소 커널에 들어간다.

왜 필요한가 — 디렉터리가 없으면 nano 가 저장할 곳을 못 찾으므로 ① 을 먼저 친다. table ip lab_edgedelete 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

443433 으로 치지 않는다. 433 도 유효한 포트라 nft 가 군말 없이 받는다. 80 은 멀쩡히 넘어가므로 이 단계와 층별 확인은 다 통과하고, 다음 단계에서 HTTPS 만 안 되는 형태로 뒤늦게 터진다. 이 실험대에서 실제로 나왔던 오타다.

:::

문제가 생기면 — 80 은 되는데 HTTPS 만 안 되면 포트 숫자부터 본다.

grep dport /etc/nftables.d/lab-edge-dnat.nft

5. 호스트에 systemd 유닛을 쓰고 켠다

목적 — 부팅할 때마다 DNAT 파일을 적용하고, libvirt 의 reject 앞에 구멍을 뚫는다.

sudo nano /etc/systemd/system/lab-edge-dnat.service
# file: /etc/systemd/system/lab-edge-dnat.service
[Unit]
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
After=network-online.target libvirtd.service
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
ExecStop=/usr/sbin/nft delete table ip lab_edge

[Install]
WantedBy=multi-user.target
ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service
sudo systemctl daemon-reload
sudo systemctl enable --now lab-edge-dnat.service

예상 결과 — ③ 에서 두 줄이 다 나와야 한다. 한 줄이라도 No such file 이면 다음 명령은 무조건 실패한다. enable --now 가 심볼릭 링크를 만들고 유닛을 한 번 돌리는데, Type=oneshotRemainAfterExit=yes 라 프로세스는 안 남고 상태만 active 로 남는다.

왜 필요한가 — 규칙의 알맹이는 두 줄이고 둘이 사는 곳이 다르다.

하는 일 어디에
tailnet 으로 들어온 80 과 443 을 192.168.122.10 으로 넘긴다 우리 테이블 lab_edge (.nft 파일)
그 주소로 가는 새 연결을 통과시킨다 libvirt 테이블 libvirt_networkguest_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-activeactive 라는 것은 DNAT 이 실렸다는 뜻이지 구멍이 뚫렸다는 뜻이 아니고, 그 상태는 호스트 안에서는 되는데 밖에서만 안 되는 형태로 아래 확인 ③ 에서야 드러난다. 이 실험대가 그 상태를 일부러 만들어 확인해 보지는 않았다.

손으로 한 번 넣어 볼 때와 날아갔을 때 다시 넣을 때는 이렇다.

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
sudo nft -a list chain ip libvirt_network guest_input
sudo systemctl restart lab-edge-dnat.service
sudo nft list table ip lab_edge

마지막 명령에서는 세 가지를 본다. dnat to 192.168.122.10 한 줄이 있는가, 포트가 80, 443 인가, 그리고 masqueradesnat 이 없는가.

:::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 구멍 유닛의 ExecStartPostguest_input 맨 앞에 insert

PREROUTING nat 은 라우팅 결정보다 먼저 도므로 이 규칙이 호스트 자신의 443 소켓보다 우선한다. 그래서 물리 호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 가고, 전환이 원자적이며 되돌리기도 한 줄이 된다.

끝났는지 판정한다

한 번에 밖에서 치지 말고 가까운 층부터 본다. 네 명령이 각각 다른 층을 건너뛰므로 어디서 끊겼는지가 바로 나온다.

# 무엇을 건너뛰나 이 단계까지 했을 때 다 끝난 실험대에서
192.168.122.11 nginx 를 건너뛴다 404 404
192.168.122.10 DNAT 을 건너뛴다 404 301
③ 도메인 밖에서 404 301 https://auth.hyeonworks.com/
④ 도메인 · TLS 이후 (아직 안 된다) 200

칸이 둘인 까닭. 이 단계가 쓰는 설정에는 listen 80 블록 하나뿐이고 그 안은 proxy_pass 다. 리다이렉트를 낼 블록이 없으니 301 은 여기서 나오지 않는다. 다음 단계가 443 블록과 80→443 리다이렉트를 얹어야 그 값이 나온다. 오른쪽 칸을 그대로 기준으로 삼으면 정상인 404 를 실패로 읽는다.

처음 볼 때는 응답을 눈으로 읽는 형태로 치고, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할 때만 값만 뽑는 형태로 바꾼다.

확인 ① Traefik 이 듣고 있나

무엇을 확인하는가 — 게스트의 80 을 누가 듣고 있는지. nginx 를 건너뛴다.

curl -I http://192.168.122.11

어디를 봐야 하는가 — 첫 줄의 상태 줄 하나, 그리고 그 앞에 아무 오류도 없이 헤더가 나왔다는 사실. curl: (7) Failed to connect 이면 응답 자체가 없는 것이라 상태 코드를 볼 일도 없다.

이 결과가 의미하는 것404 가 성공 신호다. 게스트의 80 을 Traefik 이 듣고 있고, 요청을 받아 「매칭되는 Ingress 규칙이 없다」고 답했다. 502 면 Traefik 은 떴는데 그 뒤 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 앞 단계로 돌아간다.

두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다.

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 을 건너뛴다.

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10

어디를 봐야 하는가 — 엣지가 답을 하는가. 이 단계에서 나오는 코드는 404 다. 2026-09-17 에 이 절을 그대로 따라 엣지를 세우고 재 보니 이렇게 나왔다(observed).

HTTP/1.1 404 Not Found
Server: nginx/1.22.1
Content-Type: text/plain; charset=utf-8
Content-Length: 19
404 page not found

이 결과가 의미하는 것Server:nginx/1.22.1 이고 본문이 Traefik 의 404 page not found 라는 것이 엣지가 받아서 Traefik 으로 넘겼다는 증거다. 이 층의 통과 기준은 코드값이 아니라 「curl: (7) 없이 응답이 온다」와 Server: nginx 두 가지다. 여기서 막히면 문제는 엣지 안이다. 통과하는데 아래 ③ 이 안 되면 문제는 DNAT 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.

확인 ③ 밖에서, 즉 DNAT 을 거쳐 닿나

무엇을 확인하는가 — DNS 와 DNAT 과 80 리스너가 전부 살아 있는지.

curl -I http://auth.hyeonworks.com

어디를 봐야 하는가 — 상태 줄과 Location: 헤더 한 줄. Locationhttps:// 로 시작하고 원래 호스트명을 그대로 들고 있는가. 설정에 $host 대신 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.

이 결과가 의미하는 것301 이 나왔다는 것은 바깥 요청이 DNAT 을 거쳐 엣지 nginx 까지 닿았다는 뜻이다. ① 은 Traefik 에 직접, ② 는 엣지에 직접 친 것이라 DNAT 을 안 거쳤다. 응답이 아예 없으면 nginx 가 안 떴거나 80 이 막혔다. 값을 반복해서 잴 때의 형태와 이 실험대의 실측은 이렇다(observed).

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
301 https://auth.hyeonworks.com/

301 은 다음 단계 이후의 값이다. 여기까지만 했을 때 나오는 것은 302 이고 보내는 곳도 다르다. 2026-09-17 에 밖에서 재 보니 이랬다(observed).

302 https://auth.hyeonworks.com/admin/

리다이렉트를 내는 것이 둘이고, 어느 쪽이 답하느냐가 단계에 따라 바뀐다. 04 를 끝내면 엣지 nginx 의 return 301 https://$host$request_uri 가 먼저 답해 301 https://auth.hyeonworks.com/ 이 되고, 03 까지만 했으면 그 블록이 없으므로 요청이 Keycloak 까지 가서 Keycloak 이 //admin/ 으로 보낸다. 코드도 301 이 아니라 302 다.

Locationhttps:// 인 것도 엣지가 아니라 Keycloak 이 정한다 — KC_HOSTNAME=https://auth.hyeonworks.comKC_HOSTNAME_STRICT=true 가 그렇게 시킨다. 요청이 http 로 들어와도 Keycloak 은 자기가 아는 주소로 답한다.

같은 엣지에 어떻게 치느냐가 코드를 가른다(2026-09-17, observed). 이 표를 먼저 보고 층 ②③ 을 읽는다.

어떻게 치나 코드
Host 없이 엣지 IP 로 (층 ②) 404 — Traefik 에 매칭되는 Ingress 가 없다
Host: auth.hyeonworks.com 으로 엣지 IP 에 302 https://auth.hyeonworks.com/admin/
밖에서 도메인으로 (층 ③) 302 https://auth.hyeonworks.com/admin/
밖에서 도메인 + /realms/master 200

마지막 줄이 중요하다 — TLS 를 얹기 전에도 /realms/master200 이다. 층 ④ 의 200 은 「TLS 가 섰다」가 아니라 「끝까지 이어졌다」를 재는 값이고, https 로 그 값을 받는 것이 04 의 몫이다.

밖에서 닿지 않을 때의 모양도 적어 둔다. 2026-09-17 에 구멍을 뚫기 전에는 이랬다(observed).

curl: (7) Failed to connect to auth.hyeonworks.com port 80 after 54 ms

같은 시각에 호스트 안에서 친 http://192.168.122.10404 로 답했다. 아래 「막히면」 표의 마지막 줄이 가리키는 상태 그대로였고, 원인도 거기 적힌 대로였다 — 그 호스트에 깔려 있던 유닛에 ExecStartPost 줄이 없었다. 아래 「배포된 것과 적어 둔 것이 다르다」를 본다. 그 줄을 넣자 같은 curl302 를 받았다(observed).

확인 ④ 끝까지 닿나

무엇을 확인하는가 — nginx 에서 Traefik 을 지나 파드까지 2홉이 다 이어졌는지.

curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
200

어디를 봐야 하는가 — 코드 한 칸. 여기서만 값을 뽑는 형태를 바로 쓰는 까닭은, 이 200 이 다음 두 단계에서 매번 같은 명령으로 다시 잴 기준값이기 때문이다.

이 결과가 의미하는 것502 는 nginx 는 살아 있는데 upstream 을 못 잡은 것이고, curl: (60) 같은 인증서 오류는 아직 다음 단계를 안 했다는 뜻이다. 여기까지는 TLS 가 없으므로 ④ 는 다음 단계에서 통과한다. 처음 보는 오류라 헤더가 필요하면 읽는 형태로 바꾼다.

curl -I https://auth.hyeonworks.com/realms/master

확인 ⑤ 로그에 무엇이 남았나

무엇을 확인하는가 — nginx 가 어느 upstream 에서 무엇으로 실패했는지.

journalctl -u nginx -p err -n 5      # 최근 에러만
journalctl -u nginx -f               # 지금 벌어지는 것

어디를 봐야 하는가 — 각 줄의 괄호 안 errno 와 그 뒤의 upstream 주소, 그리고 타임스탬프. 방금 친 요청 시각과 안 맞으면 지금 보고 있는 것은 옛 사고다.

이 결과가 의미하는 것-p err 로 걸러도 아무것도 안 나오면 nginx 는 정상이고 문제는 더 위에 있다. 둘째 명령은 띄워 놓은 채로 다른 창에서 요청을 치는 용도이고, 끝내려면 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).

:::note

nginx 에러 로그는 2048바이트에서 잘린다. 긴 URL 이 단어 중간에서 끊겨 보이면 이 제한에 걸린 것이고, 되찾으려면 access 로그를 본다. 이 실험대에서 502 원인이 error 로그에 있었는데 잘려 있었고 access 로그에는 3492자로 온전히 남아 있었다.

:::

grep oauth2/callback /var/log/nginx/access.log | tail -1
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 -sS -D - -o /dev/null http://192.168.122.10 404 · Server: nginx/1.22.1
층 ③ curl -I http://auth.hyeonworks.com 302 · Locationhttps://auth.hyeonworks.com/admin/
층 ④ 를 http 로 미리 curl -s -o /dev/null -w '%{http_code}\n' http://auth.hyeonworks.com/realms/master 200

층 ④ 를 https 로 받는 것은 다음 단계가 인증서를 얹은 뒤다. 층 ③ 의 301 도 그때 나온다 — 여기서는 302 가 통과이고, 그 둘을 내는 주체가 다르다(위 표).

막히면

증상 어디서 끊겼나 확인
① 이 연결 거부 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 은 안 깐다 ls /etc/nginx/which nginx 는 깔려 있어도 빈손이라 못 가른다
nginx -tduplicate default server sites-enabled/default 가 살아 있다 ls -l /etc/nginx/sites-enabled/
설정을 썼는데 아무 변화가 없다 경로 오타 — site-available(단수)에 썼다 ls /etc/nginx/sites-available/
unknown directive "http2" nginx < 1.25.1. Debian 12 는 1.22 다 sudo nginx -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_inputreject. 구멍이 빠졌거나 날아갔다 sudo nft -a list chain ip libvirt_network guest_inputreject 줄의 카운터가 올라가면 여기다
Error: syntax error, unexpected ct 셸이 {80,443} 을 펼쳤다 '{80,443}' 로 따옴표

배포된 것과 적어 둔 것이 다르다

2026-09-17 에 이 절차를 따라 실험대를 다시 세우다 호스트의 두 파일을 열어 봤더니 위에 실은 내용과 달랐다(observed). 저장소 원본을 고친 뒤 호스트에 다시 배포하지 않았다 — 파일 시각이 그렇게 말한다.

파일 호스트에 깔린 것 (11:00 · 10:52) 저장소 원본과 위 본문 (13:30)
lab-edge-dnat.nft prerouting 뒤에 forward 체인이 그대로 있다 (priority filter - 10 · ct state new accept) 그 체인이 없다. 자리에 왜 뺐는지 주석만 남겼다
lab-edge-dnat.service ExecStartPost 줄이 없다 ExecStartPost=-…insert rule ip libvirt_network guest_input… 이 있다

이것이 위 확인 ③ 의 curl: (7) 을 그대로 설명한다. 고친 쪽의 요점은 앞 체인의 accept 로는 뒤 체인의 reject 를 못 막으니 구멍은 libvirt 체인 안에 뚫는다는 것인데, 호스트에는 효과가 없는 쪽만 깔려 있고 효과가 있는 쪽은 안 깔려 있다. 게다가 그 구멍은 런타임 상태라 libvirt 가 네트워크를 다시 세우면 날아가고, 다시 넣어 줄 ExecStartPost 가 유닛에 없으니 실험대를 다시 세운 직후에는 밖에서 못 들어온다.

그래서 4번과 5번은 「이미 있으니 건너뛴다」로 넘기지 않는다. 파일이 있는 것과 그 파일이 이 문서가 싣는 내용인 것은 다르다. 두 파일을 연 다음 위 본문과 한 줄씩 대조한다.

grep -c 'chain forward' /etc/nftables.d/lab-edge-dnat.nft
grep -c ExecStartPost /etc/systemd/system/lab-edge-dnat.service

01 이 나와야 맞다. 반대로 나오면 고치기 전 판이 깔려 있는 것이고, 다시 써서 sudo systemctl daemon-reload && sudo systemctl restart lab-edge-dnat.service 로 올린다.

2026-09-17 늦게 그 sudo 가 열려 셋을 마저 봤다(observed). 결론은 절반만 고쳐져 있다이다.

grep -c 'chain forward'   /etc/nftables.d/lab-edge-dnat.nft      →  1   (0 이어야 한다)
grep -c ExecStartPost     /etc/systemd/system/lab-edge-dnat.service  →  1   (맞다)

유닛에는 ExecStartPost 가 들어갔는데 .nft 는 아직 옛 판이다 — 효과 없는 forward 체인을 아무도 걷어내지 않았다. 위 표의 두 줄 가운데 아래만 고쳐졌으므로, .nft 를 이 문서의 4번 내용으로 다시 쓰고 sudo systemctl restart lab-edge-dnat.service 로 올려야 이 문서가 싣는 판이 된다.

sudo nft list chain ip libvirt_network guest_input
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 44 bytes 2640 accept
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 0 bytes 0 accept
oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 141705 bytes 2375116597 accept
oif "virbr0" counter packets 5 bytes 300 reject
oif "virbr0" ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new counter packets 10 bytes 600 accept

구멍이 하나가 아니라 셋이다. ExecStartPostnft insert 로 맨 앞에 끼워 넣기 때문에, 유닛을 다시 시작할 때마다 같은 규칙이 한 줄씩 늘어난다. 이 명령은 여러 번 쳐도 안전한 형태가 아니다. 기능은 멀쩡하다 — 맨 앞 하나가 통과시키고 나머지는 카운터가 안 오르거나(둘째 줄 0) 아예 닿지 않는다(다섯째 줄은 reject 뒤라 새 패킷이 거기까지 못 간다).

reject 줄의 카운터는 5 다. 구멍이 들어가기 전에 막힌 것들이고, 들어간 뒤로는 안 오른다. 맨 앞 줄이 44 를 세고 있는 것이 지금 밖에서 오는 요청이 그 규칙으로 통과한다는 뜻이다.

클러스터 안에서도 공개 이름에 못 닿는다 — 이 문서에 없던 또 한 단계

랩 호스트만의 일이 아니다. 파드 안에서도 같은 이름이 같은 이유로 막힌다(2026-09-17, observed). 이름은 호스트의 tailnet 주소로 풀리는데 그 포트를 듣는 것이 없다.

kubectl -n keycloak-lab exec keycloak-0 -- sh -c "getent hosts auth.hyeonworks.com"
100.83.212.4    auth.hyeonworks.com

같은 파드에서 100.83.212.4 의 80 과 443 을 두드리면 둘 다 닫혀 있고, 엣지 게스트의 192.168.122.10:80 은 열려 있다. 길은 있는데 이름이 그 길을 안 가리킨다. 클러스터 DNS 어디에도 그 매핑이 없다 — CoreDNS 의 NodeHosts 에는 노드 둘뿐이고 노드의 /etc/hosts 에도 없다(observed).

여기서 걸리는 것은 토큰을 검증하는 앱이다. 토큰을 받는 쪽은 클러스터 안 주소를 따로 갖고 있어서 안 걸리는데, 검증하는 쪽은 공개 이름 하나만 갖고 있어서 걸린다.

고치는 한 단계는 CoreDNS 에 서버 블록을 하나 더 주는 것이다.

nano coredns-custom.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-custom
  namespace: kube-system
data:
  hyeonworks.server: |
    hyeonworks.com:53 {
      hosts {
        192.168.122.10 auth.hyeonworks.com
        192.168.122.10 app1.hyeonworks.com
        192.168.122.10 app2.hyeonworks.com
        fallthrough
      }
      forward . /etc/resolv.conf
    }
kubectl apply -f coredns-custom.yaml
kubectl -n kube-system delete pod -l k8s-app=kube-dns --wait=false

이 실험대는 같은 매니페스트를 kubectl apply -f - <<'YAML' 힙독으로 흘려 넣었다(observed). 힙독은 ConfigMap 을 명령 안에 묻어 버려서, 서버 블록을 한 줄 고치려면 같은 힙독을 처음부터 다시 친다. 파일로 두면 열어 보고 고쳐서 ④ 를 다시 치면 된다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).

파일 이름이 .server 인 것이 핵심이다. k3s 는 *.override 를 기본 서버 블록 안에 끼워 넣는데, 그 블록은 이미 hosts /etc/coredns/NodeHostshosts 를 한 번 쓰고 있다. .overridehosts 를 또 쓰면 CoreDNS 가 안 뜬다.

```text label=".override 로 넣었을 때 — CrashLoopBackOff" plugin/hosts: this plugin can only be used once per Server Block


`.server` 는 **별개의 서버 블록**으로 들어가므로 그 안에서 `hosts` 를 처음 쓰는 것이 된다. 뜨고 나면 로그 머리에 듣는 영역이 둘로 찍힌다.

```text label="제대로 들어갔을 때"
.:53
hyeonworks.com.:53
kubectl -n keycloak-lab exec keycloak-0 -- sh -c "getent hosts auth.hyeonworks.com"
192.168.122.10  auth.hyeonworks.com

이제 192.168.122.10:80 이 파드에서 열린다. 443 은 여전히 닫혀 있다 — 그것은 다음 단계가 인증서를 얹어야 열린다.

이 단계를 밟고 나면 랩 호스트에서 공개 URL 을 못 친다

nginx 가 호스트에 있던 동안에는 호스트에서 curl https://auth.hyeonworks.com 이 그냥 됐다. 이 단계가 nginx 를 엣지 게스트로 옮기면서 그것이 끊긴다. 2026-09-17 에 재 보니 80 도 443 도 안 열린다(observed).

curl -s -m 10 -o /dev/null -w "%{http_code}\n" https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
000
Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server

이유는 두 줄이다. auth.hyeonworks.com 은 호스트 자신의 tailnet 주소로 풀리고, 호스트에는 그 포트를 듣는 것이 없다.

getent hosts auth.hyeonworks.com
ss -lnt | grep -E ":(80|443) "
100.83.212.4    auth.hyeonworks.com

두 번째 줄은 아무것도 안 낸다. 4번의 DNAT 은 iifname "tailscale0" 만 매칭하므로 호스트가 스스로 낸 패킷은 그 규칙을 안 탄다. 그래서 밖에서 치는 확인은 tailnet 에 붙은 다른 기계에서 친다 — 04 가 자기 4번 확인을 엣지가 아닌 다른 머신으로 보낸 것과 같은 이유다.

랩 호스트에서 꼭 쳐야 하면 이름은 그대로 두고 주소만 엣지로 못박는다.

curl -s -m 10 --resolve auth.hyeonworks.com:80:192.168.122.10 \
  http://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

이 형태는 응답을 받는다(observed). 다만 DNAT 을 안 거치므로 층 ③ 을 재는 것이 아니다. 층 ② 를 도메인 이름으로 재는 것이고, 4번과 5번이 제대로 들어갔는지는 여전히 밖에서 쳐야 갈린다.

X-Forwarded-For 계약은 지금 성립하지 않는다

4번에서 SNAT 을 안 거는 이유로 「masquerade 를 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 보게 된다」고 적었다. 2026-09-17 에 밖에서 한 번 치고 엣지의 로그를 열었더니 이미 그 상태였다(observed).

curl -s -o /dev/null http://app1.hyeonworks.com/api/echo
sudo tail -3 /var/log/nginx/access.log
192.168.122.1 - - [17/Sep/2026:07:10:45 +0000] "GET /api/echo HTTP/1.1" 200 617 "-" "curl/8.5.0"

lab_edge 테이블에는 masqueradesnat 도 없다. 4번이 싣는 파일 그대로다. 그런데도 출발지가 덮여 있으므로 덮는 것은 이 테이블이 아니다. 덮는 것은 Tailscale 이다(2026-09-17, observed). 호스트의 규칙을 전부 읽어서 갈랐다.

sudo nft list ruleset | grep -nE 'masquerade|snat'
sudo iptables -t nat -S | grep ts-postrouting
sudo tailscale debug prefs | grep -i snat
chain ts-forward {  iifname "tailscale0" counter ... xt target "MARK"  }
-A ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
"NoSNAT": false

읽는 순서는 이렇다. tailscale0 으로 들어와 전달되는 패킷에 Tailscale 이 표식 0x40000 을 찍고, ts-postrouting 이 그 표식이 붙은 것을 전부 MASQUERADE 한다. 마스커레이드는 나가는 인터페이스의 주소로 출발지를 바꾸는데, 이 패킷이 나가는 곳이 virbr0 이라 출발지가 192.168.122.1 이 된다. NoSNATfalse 인 것이 그 동작이 켜져 있다는 뜻이고, 그것이 Tailscale 의 기본값이다.

그래서 4번의 경고는 옳았는데 엉뚱한 테이블을 지켰다. 「masquerade 를 붙이면 엣지가 모든 클라이언트를 192.168.122.1 로 보게 된다」는 그대로 일어났고, 다만 그 masquerade 를 붙인 것이 우리 테이블이 아니라 Tailscale 이다. lab_edge 만 깨끗하게 지켜도 소용이 없다.

libvirt 의 마스커레이드는 이 일과 무관하다. 그 규칙은 ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 이라 게스트에서 밖으로 나가는 것만 고르고, 밖에서 게스트로 들어오는 이 패킷은 목적지가 그 대역 안이라 안 걸린다.

범인보다 결과가 먼저다. proxy_set_header X-Forwarded-For $remote_addr 는 시키는 대로 동작하고 있고, 다만 $remote_addr 가 이미 호스트의 브리지 주소다. 그래서 이 실험대의 X-Forwarded-For 는 진짜 클라이언트를 담지 않는다. 통과 조건에 「엣지 로그의 출발지가 밖에서 친 기계다」를 넣으면 늘 실패한다.

Traefik 이 그 헤더를 다시 덮는다 — 이 문서에 없던 한 단계

위 2번이 세운 헤더가 앱까지 가는지를 처음으로 재 봤다. 안 간다. Traefik 은 자기가 신뢰하지 않는 곳에서 온 X-Forwarded-* 를 자기 연결 기준으로 다시 쓰고, 기본값에는 신뢰 목록이 비어 있다.

curl -s http://app1.hyeonworks.com/api/echo
헤더 이 한 단계를 안 밟았을 때 밟은 뒤
x-real-ip 10.42.0.1 — flannel 게이트웨이. 엣지가 보낸 값이 사라졌다 192.168.122.1 — 엣지가 보낸 값 그대로
remoteAddr 10.42.0.1 192.168.122.1
x-forwarded-for 없다 없다

빠진 한 단계는 아래 두 줄이다. 저장소에 파일이 있는데 이 실험대의 setup 아홉 편 어디에도 이 명령이 없었다(observed).

kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
kubectl -n kube-system rollout status deploy/traefik --timeout=180s

목록에는 파드 대역 10.42.0.0/16 하나만 담는다. Traefik 의 Service 가 externalTrafficPolicy: Cluster 라 svclb 가 출발지를 덮고, 그래서 Traefik 에 닿는 주소는 엣지가 아니라 파드 대역에서 온다. 노드·호스트 대역을 같이 넣어 봤지만 위 표의 값이 하나도 안 바뀌었다 — 나타날 수 없는 대역이라 넣어도 소용이 없다(observed).

x-forwarded-for 는 이 단계를 밟아도 앱까지 안 온다(observed). 왜 사라지는지는 안 가렸다(unknown). 앞 절과 합치면 이 실험대에서는 그 헤더로 클라이언트를 가릴 수 없다. 두 번 재서 두 번 다 없었다.

무엇이 관측이고 무엇이 아닌가

  • (observed) 2026-09-11 새로 만든 엣지에서 /etc/nginx 가 없던 것, nginx -t 출력 세 줄, 다 끝난 실험대에서 잰 층별 확인 ①~④ 의 코드, 301 https://auth.hyeonworks.com/, upstream 실패 errno 세 줄, access 로그 3492자.
  • (observed) 2026-09-17 에 이 절차를 따라 엣지를 다시 세우고 잰 것 — nginx -t 가 두 줄인 것, which nginx 가 깔린 상태에서도 빈손인 것, 층 ① 이 .11·.12 둘 다 404 인 것, 층 ② 가 404 · Server: nginx/1.22.1 인 것, 층 ③ 이 curl: (7) 인 것, 호스트의 두 파일이 위 본문과 다른 것.
  • (observed) 2026-09-17 에 밖에서 친 요청이 엣지 access 로그에 192.168.122.1 로 적힌 것, 이 한 단계를 밟기 전 앱이 받은 x-real-ip10.42.0.1 이고 밟은 뒤 192.168.122.1 인 것, 두 상태 모두 x-forwarded-for 가 없는 것, setup 아홉 편에 traefik-forwarded-headers.yaml 을 거는 명령이 없는 것.
  • (observed) 엣지에 닿기 전에 출발지를 덮는 것은 Tailscale 이다. ts-forward 가 표식 0x40000 을 찍고 ts-postrouting 이 그 표식에 MASQUERADE 를 걸며 NoSNATfalse 다. 호스트에 깔린 두 파일의 실제 상태(.nft 는 옛 판, 유닛은 새 판)와 guest_input 의 중복 규칙 셋도 같이 봤다.
  • (unknown) x-forwarded-for 가 어디서 사라지는지는 아직 안 가렸다. Traefik 에 신뢰 목록을 주기 전과 뒤 둘 다 앱까지 안 오는 것만 쟀다.
  • (observed) 위에 실은 .nft 와 유닛의 내용은 저장소 원본과 같다. 다만 호스트에 실제로 깔려 있는 두 파일은 그것이 아니다 — 「배포된 것과 적어 둔 것이 다르다」를 본다.
  • (inferred) 이 이동으로 L7 홉 수가 2홉 그대로라는 판정이 이 배치의 전제다. 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 홉 수 자체는 맞다. 다만 홉 수가 그대로여도 X-Forwarded-* 계약은 성립하지 않았다 — 위 두 절에서 실제로 재 보니 클라이언트 주소가 엣지에 닿기 전에 덮이고, Traefik 이 신뢰 목록 없이 한 번 더 덮었다.
  • (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) 세우는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.