--- 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 구멍이 필요한가** 이 절차의 유닛이 뚫는 구멍이 다른 백엔드에서도 필요한지는 아직 재지 않았다. ## 본문 ## 읽기 전에 — 어디서 치는가 이 단계는 기계 둘을 오간다. | 번호 | 무엇 | 어디서 | |---|---|---| | 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) 세우는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.