기록 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>
33 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 |
|
|
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 유닛. 셋 다 사람이 내용을 읽고 고쳐야 하는 파일이라 편집기로 연다. 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 이 까는 것은 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번 앞까지가 게스트 셸이다.
ssh kc-lab-edge
무엇을 확인하는가 — 엣지에 nginx 가 이미 깔려 있는지.
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
이 결과가 의미하는 것 — /etc/nginx 디렉터리는 패키지가 만든다. 그것이 없다는 것은 경로를 잘못 짚었다는 뜻이 아니라 설치가 안 됐다는 뜻이다. 앞 단계의 cloud-init 목록에 nginx 가 없으므로 1번에서 깐다.
실행 절차
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-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 으로 넘기게 한다.
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-Proto 가 http 인 것도 지금이 그렇기 때문이고, 다음 단계에서 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
예상 결과 — 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
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 을 쓰게 된다.
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_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 만 안 되면 포트 숫자부터 본다.
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=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 이 실렸다는 뜻이지 구멍이 뚫렸다는 뜻이 아니고, 그 상태는 호스트 안에서는 되는데 밖에서만 안 되는 형태로 아래 확인 ③ 에서야 드러난다. 이 실험대가 그 상태를 일부러 만들어 확인해 보지는 않았다.
손으로 한 번 넣어 볼 때와 날아갔을 때 다시 넣을 때는 이렇다.
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 인가, 그리고 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 를 건너뛴다.
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
어디를 봐야 하는가 — 301 과 Location 이 나오는가.
이 결과가 의미하는 것 — 여기서 막히면 문제는 엣지 안이다. 통과하는데 아래 ③ 이 안 되면 문제는 DNAT 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
확인 ③ 밖에서, 즉 DNAT 을 거쳐 닿나
무엇을 확인하는가 — DNS 와 DNAT 과 80 리스너가 전부 살아 있는지.
curl -I http://auth.hyeonworks.com
어디를 봐야 하는가 — 상태 줄과 Location: 헤더 한 줄. Location 이 https:// 로 시작하고 원래 호스트명을 그대로 들고 있는가. 설정에 $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/
확인 ④ 끝까지 닿나
무엇을 확인하는가 — 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 -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) 세우는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.