Files
document-haness/docs/virtualization/tech-log-studio/lab-environment-build/case/case-nftables-accept-did-not-stop-the-libvirt-reject.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

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

12 KiB

kind, slug, title, topic, topicName, project, status, assets, sourceRevision, source
kind slug title topic topicName project status assets sourceRevision source
CASE nftables-accept-did-not-stop-the-libvirt-reject 호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다 lab-environment-build 실험대 환경 구성 virtualization 게시 전
key file
nftables-forward-hook-chain-order ../../../final/assets/diagrams/nftables-forward-hook-chain-order/nftables-forward-hook-chain-order.svg
9465582b5d1630eb4ae7c4e078021486919bf6b6
final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
final/document.md#178-이-부의-출처와-범위

호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다

밖에서 온 요청은 먼저 돌게 해 둔 forward 체인의 accept 를 지나고도 libvirt 의 guest_input 체인 끝 reject 에서 끊겼다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 그래서 구멍을 libvirt 체인 맨 앞에 넣었다.

관계

  • 엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리 그 결정으로 새로 필요해진 libvirt 방화벽 구멍을 이 사건에서 실제로 뚫었다.
  • libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 에 구멍이 필요한가 여기서 재지 않고 넘긴 미확인 항목을 그 질문이 받는다.
  • 단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다 같은 구축에서 나온 문서 결함들을 하나의 규칙으로 정리한 글이다.
  • Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge 밖에서 온 패킷이 게스트에 닿기까지의 경로를 그 글이 세우고, 이 사건은 그 경로의 한 구간에서 막혔다.
  • packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다 어느 계층까지 패킷이 보이는지로 의심 구간을 좁히는 절차이고, 여기서는 규칙 카운터가 같은 일을 했다.

문제

엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴 뒤 밖에서 들어오는 요청만 엣지에 닿지 않았다.

호스트에서 친 요청 : curl http://192.168.122.10 → 404, 엣지 nginx 가 응답한다 밖에서 친 요청 : curl http://100.83.212.4 → connection refused

호스트에서 친 요청에는 404 를 돌려줬으니 엣지 nginx 는 게스트 안에서 돌고 있었다. 밖에서 친 요청만 끊겼고, 타임아웃이 아니라 즉시 거절이었다.

결론

밖에서 온 패킷을 거절한 것은 libvirt 가 만든 규칙이다. libvirt 는 자기 테이블 ip libvirt_network 의 guest_input 체인을 ct state established,related accept 다음의 reject 로 끝낸다. 그 reject 규칙의 카운터가 4 패킷 240 바이트로 밖에서 친 curl 횟수와 정확히 일치해서 범인을 확정했다.

DNAT 를 정의한 파일에는 priority filter - 10 을 줘서 먼저 돌게 한 forward 체인이 있는데, 거기 넣은 ct state new accept 가 그 reject 를 막지 못한다. nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 accept 는 그 체인을 통과했다는 뜻이지 평가가 끝났다는 뜻이 아니고, 즉시 종결하는 것은 drop 뿐이다.

해결 : 구멍을 libvirt 체인 맨 앞에 넣는다. insert 가 맨 앞이고 add 가 맨 뒤다. 수명 : libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 그 규칙이 날아간다. 그래서 DNAT 유닛의 ExecStartPost 에 넣는다.

검증 환경

호스트 : test-server, Arch Linux CPU : i5-1135G7, 논리 코어 8 RAM : 11,648MiB QEMU : 11.1.1 libvirt : 12.7.0 libvirt firewall_backend : nftables 호스트 이더넷 : 없다 — WiFi 만 있다 게스트 네트워크 : libvirt NAT, virbr0 게스트 : Debian 12 genericcloud 3대 — 엣지 1대(nginx·certbot), k3s 2노드 엣지 게스트 주소 : 192.168.122.10

재현 조건

  1. 엣지 nginx 를 게스트 192.168.122.10 에 두고, 호스트 커널의 DNAT 로 밖에서 들어온 요청을 그 게스트로 넘긴다.
  2. 호스트에서 curl http://192.168.122.10 을 친다. 엣지 nginx 가 404 로 응답한다.
  3. 밖에서 curl http://100.83.212.4 를 친다. connection refused 가 온다.
  4. libvirt 테이블 ip libvirt_network 의 guest_input 체인을 규칙과 카운터까지 덤프한다. 마지막 reject 규칙의 패킷 수가 3번을 친 횟수와 맞으면 그 규칙이 그 패킷을 끝냈다.
  5. guest_input 맨 앞에 구멍을 넣고 3번을 다시 친다.

본문

엣지 nginx 를 게스트로 옮기고 새로 필요해진 것

이 호스트에는 이더넷이 없고 WiFi 만 있어서 브리지를 못 쓰고, libvirt NAT(virbr0)에 호스트로 들어온 요청을 넘기는 구조를 택했다. 그 위에 Debian 12 게스트 세 대가 있고, 그중 한 대가 nginx 와 certbot 을 돌리는 엣지다. 엣지 nginx 는 원래 호스트에 있었고 사는 곳만 게스트로 바꿨다.

전:  tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
후:  tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)

L7 홉 수는 전후 모두 2홉이고 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 X-Forwarded-* 계약은 그대로 성립한다. 옮긴 이유도 성능이 아니라 더러워지는 층의 격리였다. nginx 설정과 인증서, certbot, deploy 훅은 자주 갈아엎는 것들인데 호스트에 있으면 초기화가 불가능하고, 엣지 장애 실험이 SSH 까지 위험하게 만든다.

그 대가로 일곱 가지가 새로 필요해졌는데 그중 둘은 배포판 차이가 아니라 패킷이 지나는 길이 달라져서 생겼다. 하나는 DNAT(Destination NAT) 다. 들어온 패킷의 도착지 주소를 바꿔 다른 기계로 넘기는 것을 말한다. 전에는 호스트가 직접 :443 을 들었으니 넘길 일이 없었는데 지금은 호스트에 리스너가 아예 없다. 다른 하나는 libvirt 방화벽에 구멍을 내는 일이다. 호스트가 게스트에 접속할 때는 OUTPUT 경로라 필터를 안 탔지만, 밖에서 게스트로 들어오는 것은 FORWARD 다. 「호스트가 게스트에 접속한다」와 「밖에서 게스트로 들어온다」는 커널이 보기에 완전히 다른 일이다. 그 구멍을 뚫는 데서 이 구축이 가장 오래 막혔다.

호스트 안에서는 되는데 밖에서만 안 된다

엣지 nginx 는 호스트에서 친 요청에 404 를 돌려줬으니 게스트 안에서 살아 있었는데, 밖에서 친 요청만 끊겼다.

어디서 쳤나 결과
호스트에서 curl http://192.168.122.10 404, 엣지 nginx 가 응답
밖에서 curl http://100.83.212.4 connection refused

패킷을 조용히 버리는 drop 이면 클라이언트가 응답을 기다리다 죽으므로, 타임아웃이 아니라 즉시 거절이 돌아왔다는 것이 단서였다.

guest_input 체인 끝의 reject 와 카운터 4 패킷

libvirt 는 자기 테이블 ip libvirt_network 안에 guest_input 체인을 만들고, 이미 맺어진 연결과 그에 딸린 연결만 통과시킨 다음 나머지를 거절하는 규칙으로 그 체인을 끝낸다.

oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related accept
oif "virbr0" counter packets 4 bytes 240 reject          ← 여기서 죽는다

규칙 하나를 지목해 놓고 시작한 것이 아니다. nft list ruleset 에서 rejectdrop 이 든 줄만 뽑아 놓고, 밖에서 친 횟수와 카운터가 맞아떨어지는 줄을 찾았다. 이 reject 규칙의 카운터가 4 패킷 240 바이트였고, 밖에서 친 curl 횟수와 정확히 일치했다. 범인 확정에 쓴 것이 이 숫자다.

왜 앞 체인의 accept 가 안 먹혔나

DNAT 를 정의한 파일에는 libvirt 체인보다 먼저 돌도록 priority filter - 10 을 준 forward 체인을 두고 거기에 ct state new accept 를 넣어 두었다. 밖에서 온 패킷은 그 accept 를 지나고도 거절됐는데, nftables 가 같은 훅에 붙은 base 체인을 우선순위 순으로 전부 평가하기 때문이다. 앞 체인의 accept 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니고, 즉시 종결하는 것은 drop 뿐이다. iptables 감각으로 쓰면 정확히 여기서 틀린다.

forward 체인은 지금 DNAT 파일에 없다. 남은 체인은 prerouting 하나이고, 체인을 그냥 지우는 대신 주석 하나를 남겨 두었다 — 여기에 forward 체인을 두지 않은 것이 의도이며 구멍은 유닛의 ExecStartPost 가 libvirt 자기 체인 안에 넣는다는 내용이다.

밖에서 온 패킷 하나가 forward 훅에 붙은 base 체인 둘을 우선순위 순으로 지나는 경로도. 왼쪽 점선 상자가 priority filter - 10 인 forward 체인이고 그 안에 ct state new accept 가 있다. 오른쪽 점선 상자가 ip libvirt_network 의 guest_input 체인이고 그 안에 맨 앞의 inserted accept 와 맨 끝의 reject 두 상자가 있다. 점선 화살표는 구멍을 넣기 전의 경로로 체인 끝 reject 를 지나 connection refused 로 끝나고, 실선 화살표는 넣은 뒤의 경로로 맨 앞의 구멍을 지나 엣지 nginx 로 간다.

점선 상자 두 개가 같은 훅에 붙은 base 체인 둘이고, 왼쪽이 먼저 돈다. 점선 화살표는 구멍을 넣기 전의 경로다 — 앞 체인의 accept 를 지난 패킷이 guest_input 으로 이어지고, 위 덤프에 적힌 ct state established,related accept 에 걸리지 못한 채 체인 끝 reject 에 닿아 connection refused 로 끝난다.

실선 화살표는 다음 절에서 뚫을 구멍을 지나는 경로다. guest_input 상자 안에 놓인 두 규칙의 위아래가 체인 안의 순서이고, 구멍이 위에 있어서 같은 패킷이 아래의 reject 를 보기 전에 그 규칙에서 accept 된다.

구멍은 맨 앞에 넣고, 네트워크를 다시 세울 때 다시 넣는다

그래서 구멍을 libvirt 체인 맨 앞에 뚫었다. insert 가 맨 앞이고 add 가 맨 뒤다.

nft insert rule ip libvirt_network guest_input \
  oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept

이 규칙은 휘발성이다. libvirt 가 네트워크를 다시 세우면 guest_input 을 새로 쓰면서 규칙이 날아가므로, DNAT 유닛의 ExecStartPost 에 넣어 네트워크가 다시 설 때마다 같은 규칙이 다시 들어가게 했다.

ExecStartPost 줄 앞에는 - 를 붙였다. libvirt_network 테이블은 가상 네트워크가 올라온 뒤에야 생기므로, 그 전에 유닛이 뜨면 이 줄이 실패한다. - 를 붙여 두면 그때도 DNAT 은 그대로 올라가고 구멍만 빠지며, 빠진 구멍은 유닛을 다시 시작해서 넣는다. 그리고 그렇게 걸어 둔 뒤 libvirt 네트워크를 실제로 다시 세워 규칙이 되돌아오는지는 확인하지 않았다.

확인하지 못한 것

이 호스트 한 대에서만 봤다. libvirt 의 firewall_backend 가 iptables 인 호스트에서도 같은 구멍이 필요한지는 재지 않았고, 이 호스트는 nftables 백엔드다.

카운터 4 패킷 240 바이트는 위에 옮긴 규칙 덤프에 찍힌 값이다. 그 덤프와 curl 출력의 원문은 final/evidence/ 에 파일로 남기지 않았다. 같은 재현을 다시 돌려 출력을 파일로 남기면 그때 원문을 댈 수 있다.