1P by GN⁺ | ★ favorite | 댓글 1개
  • Fly.io는 flyctl과 Fly Machines 간 직접 통신을 유지하면서 WireGuard 게이트웨이의 상태 부담을 줄이기 위해, 피어를 미리 설치하지 않고 연결 시점에 커널에 추가하는 방식으로 바꿈
  • 기존 흐름은 GraphQL API가 NATS RPC로 피어 설정을 전달하고 wggwd가 SQLite와 Linux 커널 WireGuard에 등록한 뒤 flyctl이 접속하는 구조였음
  • NATS 메시지 유실과 CI 작업의 일회성 피어 생성이 겹치면서, 게이트웨이에 수십만 개의 재사용되지 않는 피어가 쌓이고 커널 작업과 재부팅 로딩이 느려짐
  • 새 방식은 handshake initiation 패킷을 BPF 필터나 WebSockets 수신 경로에서 잡고, Noise 핸드셰이크 일부를 복호화해 공개 키를 식별한 뒤 내부 HTTP API로 필요한 피어만 가져옴
  • 운영 적용 후 몇 주 동안 오래된 피어 수가 거의 사라졌고, 게이트웨이는 더 적은 상태로 더 빠른 피어 설정과 재부팅을 처리할 수 있게 됨

Fly.io가 WireGuard를 쓰는 방식

  • Fly.io는 컨테이너를 Firecracker 기반 VM으로 실행하며, 고객 API의 일부처럼 WireGuard를 여러 곳에서 활용함
  • flyctl은 실행 시 자체 IPv6 주소를 가진 TCP/IP 스택을 만들고, Fly.io 네트워크의 Fly Machines와 직접 통신함
  • 이 접근은 원격 Docker 빌더 같은 기능을 같은 LAN에 있는 것처럼 표현하기 쉽게 만들지만, 안정적으로 계속 운영하기는 더 까다로움
  • Fly.io는 결국 기본 경로를 WireGuard-over-WebSockets로 바꿈

기존 게이트웨이 프로비저닝 흐름

  • Fly.io는 전 세계의 여러 게이트웨이 서버로 들어오는 WireGuard 연결을 적절한 사설 네트워크에 연결함
  • flyctl이 컨테이너 빌드, SSH 콘솔, 파일 복사, 서비스 프록시를 위해 Fly Machine과 통신해야 할 때 백그라운드 에이전트 프로세스를 실행하거나 연결함
  • 에이전트는 처음 실행될 때 GraphQL API에서 새 WireGuard 피어 설정을 생성함
    • 피어 설정은 공개 키와 연결할 주소로 구성됨
  • API는 해당 설정을 NATS 메시징 시스템의 RPC로 적절한 게이트웨이에 전달함
  • 게이트웨이의 wggwd는 설정을 받아 SQLite에 저장하고 WireGuard Go 라이브러리로 커널에 추가한 뒤 API에 설치 완료를 응답함
  • API가 GraphQL 요청에 설정을 반환하면, flyctl은 이미 게이트웨이에 설치된 WireGuard 피어로 연결함

기존 구조가 느려진 이유

  • NATS는 빠르지만 전달을 보장하지 않아 신뢰성 있는 API 기반으로 쓰기 어려웠음
    • Fly.io는 내부에서 NATS 사용을 줄였고, 예를 들어 내부 flyd API는 NATS 기반에서 HTTP 기반으로 바뀜
    • NATS 사용을 줄이면서 WireGuard 게이트웨이는 개선됐지만 충분하지 않았음
  • flyctl 종료 후 생성된 WireGuard 피어는 게이트웨이에 계속 남았고, 오래된 피어를 정리하는 과정이 없었음
    • 다음 날 다시 배포하거나 fly ssh console로 디버깅할 가능성이 있어 피어를 제거하지 않는 선택이 있었음
    • 하지만 대부분의 피어는 영속 스토리지가 없는 CI 작업에서 생성됐고, 다음 실행에서 같은 피어로 재접속하지 못해 매번 새 피어가 만들어짐
  • 그 결과 게이트웨이는 재사용되지 않을 수십만 개의 피어를 보유하게 됨
    • 오래된 피어 수가 많아지면서 커널 WireGuard 작업이 매우 느려짐
    • 게이트웨이 서버 재부팅 후 모든 피어를 다시 커널에 로드하는 과정이 특히 느렸음
    • 일부 커널 패닉도 발생함

피어를 필요할 때만 커널에 설치하는 설계

  • 모든 WireGuard 피어 이력을 SQLite 하나에 저장하는 것은 어렵지 않지만, 모든 피어를 Linux 커널에 유지하는 것은 병목이 됨
  • Fly.io는 게이트웨이에 설정을 푸시하는 대신, 게이트웨이가 API에서 필요한 피어를 온디맨드로 가져오는 방식을 택함
  • 클라이언트가 연결하려 할 때만 피어를 커널에 추가하면, 오래된 피어는 언제든 커널에서 제거할 수 있음
  • 제거된 피어도 다음 연결 시 다시 가져와 설치하면 되므로, 게이트웨이가 장기 상태를 계속 들고 있을 필요가 줄어듦
  • 다만 Linux 커널 WireGuard에는 “incoming connection attempt” 이벤트를 구독하는 API가 없음

JIT WireGuard 피어 구현 방식

  • Linux 커널의 WireGuard 설정 인터페이스는 Netlink이며, WireGuard Go 제어 라이브러리는 wgctrl-go를 사용함
  • Fly.io는 WireGuard 연결 요청이 식별 가능한 패킷이라는 점을 이용해 BPF 필터packet socket으로 직접 이벤트를 만듦
  • WebSockets WireGuard 경로에서는 원시 WireGuard 패킷을 더 쉽게 얻을 수 있음
    • 이 경로는 인증 없는 WebSockets 연결로 프레이밍된 UDP 패킷을 게이트웨이 인터페이스와 주고받음
    • Fly.io가 해당 데몬 코드를 보유하고 있어 패킷 수신 함수에 훅을 걸 수 있음
  • WireGuard에는 “클라이언트”와 “서버” 개념이 없고, 트래픽을 보낼 때 피어끼리 연결하는 점대점 프로토콜
    • 먼저 연결하는 쪽은 initiator, 상대는 responder임
    • Fly.io에서는 보통 flyctl이 initiator이고 게이트웨이가 responder임
  • 첫 UDP 패킷은 WireGuard 논문 기준 handshake initiation이며, 패킷 타입은 평문 1바이트에 기록됨
    • Fly.io는 udp and dst port 51820 and udp[8] = 1 BPF 필터로 들어오는 연결을 잡음

Noise 핸드셰이크에서 피어 식별하기

  • WireGuard는 Noise Protocol Framework 기반이며, Noise는 핸드셰이크 중 identity hiding을 위해 식별자를 숨김
  • 그래서 패킷에서 사용자명 같은 값을 읽어 곧바로 설정을 찾는 방식은 사용할 수 없음
  • Fly.io는 들어오는 요청을 식별하기 위해 Noise 암호화 일부를 실행해 신원을 복호화함
    • 이 코드는 까다롭지만 약 200줄 정도임
    • 커널 Netlink 인터페이스는 권한 있는 프로세스에 인터페이스의 개인 키를 제공할 수 있어 필요한 비밀값을 얻을 수 있음
    • 관련 코드는 gist에 공개됨
  • 이 과정을 거치면 게이트웨이에 WireGuard 연결을 시도하는 사용자의 공개 키 이벤트 피드를 얻을 수 있음

설치, 캐시, 재시도 최적화

  • 게이트웨이는 SQLite에 속도 제한 캐시를 유지하고, 새 피어를 발견하면 내부 HTTP API 요청으로 대응하는 피어 정보를 가져와 설치함
  • 이 로직은 기존에 게이트웨이에서 WireGuard를 관리하던 작은 데몬에 잘 들어맞음
  • 오래된 피어는 cron 작업으로 적극적으로 제거할 수 있게 됨
  • 새 피어에 대한 API 조회는 첫 handshake initiation 메시지에 즉시 응답할 만큼 빠르지 않을 수 있음
    • WireGuard는 빠르게 재시도하므로 동작 자체에는 문제가 없음
  • Jason Donenfeld가 알려준 Linux WireGuard Netlink 기능을 이용해 더 빠르게 연결을 성립시킴
    • 들어오는 initiation 메시지에서 flyctl의 임시 소스 포트를 포함한 4-튜플 주소를 얻음
    • 게이트웨이는 자신이 initiator이고 flyctl이 responder인 것처럼 피어를 설치함
    • Linux 커널이 flyctl 쪽으로 WireGuard 연결을 시작하며, 프로토콜은 서버와 클라이언트 역할에 크게 의존하지 않음
    • 새 연결은 설치 가능한 속도에 가깝게 성립됨

운영 적용 결과

  • 이 방식은 몇 주 동안 프로덕션에서 실행됨
  • 게이트웨이별로 수천 개에서 수십만 개에 이르던 오래된 WireGuard 피어 수가 거의 0에 가까워짐
  • 게이트웨이가 보유해야 하는 상태가 줄어듦
  • 피어 설정이 더 빨라짐
  • 재부팅 시 사용되지 않는 피어를 커널에 다시 로드할 필요가 줄어듦

댓글과 토론

Hacker News 의견들
  • Linux 커널 WireGuard에 필요할 때 피어를 설치하는 기능이 없다는 말이 잘 이해되지 않음. 런타임에도 피어를 추가할 수 있어 보임: https://serverfault.com/questions/1101002/wireguard-client-a...
    이해한 게 맞다면 그 단계는 이미 늦은 것이고, 인터페이스에 오래된 항목이 남지 않도록 피어를 추가하기 전에 인증하려는 듯함
    그래서 인터페이스 앞에 eBPF 필터를 두고, 암호키 라우팅 기반으로 승인된 상대인지 직접 연결해 본 뒤 통과하면 피어를 인터페이스에 추가하고 타임아웃 후 제거하는 구조로 보임

    • 결국 원하는 것은 커널 WireGuard가 initiator 메시지에서 본 공개키 목록을 흘려주는 Netlink API임. 중기적으로 Jason도 이런 기능을 제공하려는 듯하고, 그 피드가 있으면 WireGuard 피어를 미리 하나도 설치할 필요가 없음
      피어들은 전부 SQLite 같은 곳에 있다가, 클라이언트가 연결을 시도할 때 필요 시 설치하면 됨
      VPN 제공자 입장에서는 현재 API가 좀 투박함. 실제로는 어느 순간 사용 중인 피어가 일부뿐이라는 점도 있지만, 피어 수가 수십만에서 수천만으로 커지면 커널 한 인스턴스에 전부 저장하는 것 자체가 불가능해짐
      피어를 미리 설치해야 하면 결국 특정 서버 머신에 묶이게 됨
      글에서 말하듯 지금도 간단한 패킷 캡처로 필요한 인터페이스 비슷한 것을 만들 수 있고, Jason이 API를 잘 설계해 둔 덕분에 서버와 클라이언트의 개시 방향을 매우 쉽게 뒤집을 수 있음. 커널이 첫 개시 메시지를 버렸더라도, 사용자는 매끄럽게 연결되는 것처럼 느끼게 됨
      Jann Horn은 한 단계 더 나아가 캡처한 개시 패킷을 보관했다가 피어 설치 후 커널에 다시 주입할 수도 있었다고 했고, 그것도 꽤 좋은 아이디어임
      이 글이 인생을 바꿀 정도는 아니고, 사람들이 알아두면 좋아할 만한 깔끔한 트릭 몇 가지에 가깝다고 봄
      다음 단계는 이를 바탕으로 floating peers를 만들어 피어를 완전히 지역에서 분리하는 것임. 그러면 사용자는 피어가 어느 리전에 설정됐는지 신경 쓰지 않아도 되고, 이건 단순한 덕후용 재미를 넘어 실제 제품상의 이점이 있을 듯함
    • 커널 밖에서 WireGuard를 돌리는 대안을 피하려고 이렇게 한 것처럼 보임. 암호화 주소로 먼저 라우팅하는 기능이 Linux 커널에 없는데, 커널을 떠나고 싶지는 않아서 해킹으로 넣은 셈 아닌가 싶음
      JIT WireGuard라는 표현은 좀 이상하게 느껴짐. 처음 든 생각은 “왜? 성능 병목은 암호화이고 클라이언트별 JIT는 거기에 도움이 안 될 텐데”였음
      나라면 그냥 사용자 공간으로 갔을 것 같음. tokio-uring이나 glommio 같은 걸 써서 성능을 뽑으면 됨
      계속 커널 안에서 밀어붙이면 Linux가 수백만 개의 활성 터널을 처리하도록 만들어진 게 아니라서 계속 한계에 부딪힐 것임. 커널 하나에서 TCP 연결 수백만 개만 해도 때때로 까다로움
      한계마다 해킹이 필요하고, 해킹마다 적용하고 관리해야 하는 시스템 설정이 생김. Linux 물리 서버 프로비저닝 도구 체인은 앱과 서비스 개발·설정 관리 도구보다 훨씬 뒤처져 있음
      아니면 내가 멍청해서 뭔가를 잘못 이해한 건가?
  • Go 앱에서 사용자 공간 WireGuard 피어를 만들고 싶다면 최근 실험 프로젝트인 https://github.com/dpeckett/noisysockets를 봐도 좋음
    wireguard-go의 훌륭한 작업을 기반으로 했지만, 라이브러리로 쓰기 더 단순하고 Go답게 만들려고 했음
    이걸로 서비스 메시를 만들면 흥미로울 것 같음. 여러 언어를 지원하기는 어렵겠지만 소켓 API를 구현할 수도 있을 듯함
    다만 WireGuard 암호화에 대한 하드웨어 가속은 아직 못 봐서, 성능 면에서는 mTLS와 경쟁하기 어려울 수 있음
    참고로 지금 프리랜스 일을 찾고 있으니, 고속·보안 네트워킹 분야의 Golang 프리랜서가 필요하면 연락해도 됨

    • 사용자 공간 WireGuard 프로젝트를 가져다가, 앞단 릴레이에서 PAKE로 WireGuard 키를 교환하고, 이후 홀펀칭으로 직접 터널을 만드는 꿈이 있음
      임의 터널을 위한 Magic Wormhole 같은 것이고, 긴 고대역폭 네트워크에서 파일 전송이 20~30 MB/s에서 무너지는 문제도 크게 개선할 수 있기를 기대함
    • Noisy Transport가 Slack의 Nebula [0]와 어느 정도 비슷한 건지, 아니면 내가 헷갈리는 건지 궁금함
      0 - https://github.com/slackhq/nebula
  • 단일 지점 간 메시지에는 메시지 큐를 거치는 것보다 직접 HTTP 요청이 더 안정적일 수 있다는 데 대체로 동의하지만, NATS에서 메시지가 그렇게 많이 유실되어 서비스에 큰 영향을 줬다는 점은 조금 놀라움
    메시지가 유실되면 NATS가 성공할 때까지 재전송하는 것 아닌가? 왜 체감될 정도의 불안정성을 겪었는지 아는 사람이 있는지 궁금함

    • 더 자세한 내용이 매우 궁금함. NATS 유지보수자들도 마찬가지일 것 같음
      NATS의 구조는 직관적이고 매력적인데, 어디서 어긋났는지 궁금함. JetStream에는 조정 가능한 매개변수가 많음
      예를 들어 시간 기반 중복 감지 창이 있는 메모리 스트림, push/pull 방식, 재전송과 확인 정책 설정 등이 가능함
      다만 일회성 단일 메시지 연결과는 궁합이 안 맞을 수 있음. 어느 쪽이든 더 구체적인 세부사항이 있으면 매우 유용할 듯함
    • NATS를 깎아내리려는 건 아님. 아마 우리가 잘못 쓰고 있었을 가능성이 큼
      하지만 결국 우리에게는 필요 없었음. 메시지 계층이 표현력을 더해 주기보다는, 테스트와 모니터링을 더 어렵게 만들 뿐이었음
    • core NATS를 쓰는 경우라면, JetStream이 아니어서 재전송 옵션이 아예 없을 것으로 알고 있음
  • “우리가 initiator인 것처럼 피어를 설치하고 flyctl을 responder로 둔다. Linux 커널이 flyctl 쪽으로 WireGuard 연결을 다시 시작한다”는 부분은 사실상 핸드셰이크에 반 왕복 지연을 추가하는 건가?
    예를 들면 1) flyctl이 Initiation 전송, 2) netlink로 피어가 추가되며 새 Initiation 전송, 3) flyctl에서 Response 전송 같은 흐름인지 궁금함

    • 내가 읽기에는 양쪽 피어가 모두 자신이 시작했다고 “생각”하게 되지만, 실제로는 상관없는 듯함
      즉 3단계가 없거나 기다릴 필요가 없고, 2단계의 새 개시를 막으면 확실히 그렇게 되지 않을까 싶음
    • 대체로 맞음. “Bob”이 주소록에 있는 번호와만 통화할 수 있다는 정책을 갖고 있다고 생각하면 이렇게 볼 수 있음
      1. Alice가 Bob에게 전화함
        1.a) Bob은 전화를 받지 않지만, 발신자 표시의 번호를 주소록에 추가함
      2. Bob이 그 번호, 즉 Alice에게 다시 전화함
      3. Alice가 받고 둘이 행복하게 대화함
  • “flyctl을 실행할 때마다 우리의 사랑스럽고 방대한 CLI가 허공에서 TCP/IP 스택을 만들어 내고, 자체 IPv6 주소를 갖고, 우리 네트워크에서 실행 중인 Fly Machines와 직접 통신한다”는 말이 무슨 뜻인지 모르겠음

    • 기본적으로 go 구현체 같은 사용자 공간 WireGuard를 쓴다는 뜻임. 커널 내 WireGuard와 대비되는 방식임
      “허공에서 TCP/IP 스택을 만든다”고 표현한 이유는 보통 운영체제가 커널 일부로 TCP/IP 스택을 제공하기 때문임
      wireguard-go에서는 TCP/IP 스택이 사용자 공간에서 실행되므로, flyctl 명령줄 인터페이스 같은 일반 사용자 공간 프로세스 안에서 만들 수 있음
      오래전부터 시스템을 다뤄 온 사람에게는 꽤 마법처럼 보일 만함. 실제로 쓸 만한 프로세스 내 사용자 공간 TCP/IP 스택은 비교적 새롭고 참신한 것임
    • 관련해서 전체 글을 따로 썼음: https://fly.io/blog/our-user-mode-wireguard-year/
    • WireGuard를 쓴다는 뜻임
    • 사랑할 만한 방대한 CLI라는 게 잘 상상이 안 됨
  • 최초 핸드셰이크 패킷을 네트워크 스택에 다시 주입하지 못하게 막는 게 뭔지 궁금함. 그러면 패킷 손실이 없을 것 같음
    또 eBPF 필터에서 udp[8] = 1을 확인하는 목적도 궁금함

    • 막는 건 없음. 좋은 아이디어임
      옆 댓글에서 말했듯 BPF 필터는 개시 패킷만 잡아내고, 그게 원하는 동작임. TCP 연결 시작을 보려고 SYN을 스니핑하는 것의 WireGuard 버전임
    • udp[8] = 1핸드셰이크 패킷만 필터링함. 없으면 데이터 패킷도 사용자 공간 데몬으로 보내짐
      최초 핸드셰이크를 재생할 수 있는지는 확실치 않지만, WireGuard가 알 수 없는 클라이언트를 무시하므로 가능할 수도 있음
    • 키를 추가한 뒤 패킷을 풀어 주는 NFQUEUE 헬퍼처럼 들림
  • 기본값으로 WireGuard를 WebSocket 위에서 터널링한다는 점이 흥미로움. 성능에는 좋지 않지만 flyctl이 쓰이는 DevOps성 작업에는 괜찮을 듯함
    QUIC/HTTP3의 미래에 대해 생각할 때도 이런 부분이 궁금했음. 네트워크 운영자들이 UDP 443 포트를 제대로 처리하기보다 아예 막아버릴 가능성도 0은 아님

    • 네이티브 WireGuard도 당연히 쓸 수 있고, flyctl에서도 설정 옵션이 있음
      UDP가 안 되면 아예 안 되고 디버깅도 어렵기 때문에, 기본값은 우리가 확실히 동작한다고 아는 쪽으로 둔 것임
      어떤 기본값을 택할지에 대한 논쟁에서 졌다는 점은 씁쓸함
  • 내 스타트업은 Fly를 거의 1년 썼음. 코드를 1분 안에 배포된 코드로 만드는 핵심 기능은 정말 아름다움
    백필용 새 노드를 올리고 내리는 것도 몇 초면 됨
    하지만 회사 자체는 조금 미성숙하게 느껴졌음. 한 번은 API 서버가 Fly에서 48시간 동안 접근 불가였는데, 내 설정 실수였는지 또 다른 “조용한” 장애였는지 확신이 안 섰음
    “db” 제품이 있지만 “관리형 Postgres는 아님”이라는 식이고, 거기서도 끊김이 꾸준히 발생했음
    CLI에 Postgres를 최상위 명사로 추가해 놓고, 지원하는 기능 범위를 제한하는 게 이상하게 느껴졌음
    핵심 서비스 API 접근도 자주 내려가서 새 서비스 수정 배포를 기다려야 했음
    배포 경험은 그립지만, 솔직히 지금은 GCP의 Cloud Run이 더 만족스러움. “놀라움”이 훨씬 적고 문서도 훨씬 완성도 높음

    • 배포 경험은 훌륭하지만, 내게 Fly.io의 킬러 기능은 Anycast 네트워크와 FLY_REPLAY, LiteFS 같은 기능임. 이런 것들이 클러스터링을 아주 쉽게 만들어 줌
      VPS 제공자들이 사용자에 대한 백엔드 서비스 지연 시간을 줄이는 지원을 거의 안 한다는 게 신기함. Anycast를 지원하는 곳은 없고, GeoDNS 선택지도 매우 적음
      다만 GeoDNS는 별도의 복잡성을 더함
      Fly.io의 데이터 전송 비용이 더 저렴했으면 좋겠음. 지금은 작업 중인 ngrok 비슷한 서비스에서 Fly.io 기능 상당수를 서툴게 다시 구현해야 하는 상황임
      [0]: https://lastlogin.io
      [1]: LastLogin을 전 세계 분산 방식으로 실행하는 데 필요한 Fly 전용 코드는 이 정도임: https://github.com/lastlogin-io/obligator/blob/37f75cc861f1b...
    • Fly는 좋아 보이지만 직접 써볼 기회는 없었음. 다만 GCP의 Cloud Run은 내가 가장 좋아하는 인프라·배포 도구 세 손가락 안에 들어서, 기준을 꽤 높게 잡은 셈임
    • 거의 같은 경험을 했음. Fly를 1년 쓰다가 한두 달 전에 GCP로 옮겼고, 우리 경우에는 이유가 있어 GKE를 선택했음
      잘 동작할 때는 정말 매끈했지만, 그 빈도가 충분하지 않았음
  • 이 기회에 Netmaker[0]를 소개하고 싶음
    관련자는 아니고, 여러 계정에 걸친 비공개 AWS VPC 접근이 필요해서 만족스럽게 쓰는 사람일 뿐임. 더 널리 채택되면 좋겠음
    [0] https://www.netmaker.io/

    • Netmaker가 Tailscale 같은 건가? 사이트만 봐서는 차별점이 뭔지 잘 모르겠음
    • Netmaker나 비슷한 도구가 키를 대신 관리해 주는 것 같고, 그러면 관리가 훨씬 쉬워질 듯함
      이전 직장에서는 Ansible로 Windows와 Linux 몇 대에 wg를 설정하고 관리했는데, 괜찮긴 했지만 끝에는 좀 지저분해졌음
    • private link나 VPC peering으로 AWS 네이티브하게 할 수는 없는 건가? 이쪽은 잘 몰라서 Netmaker의 이점을 이해하지 못하겠음
    • 일반적인 VPN 플랫폼인가? Tailscale 같은 것과 비슷한지 궁금함
      사이트가 너무 모호함
  • “수십만 개의 피어가 있는 게이트웨이, 그중 다시는 쓰이지 않을 피어들”이라는 부분이 첫 문단들을 읽으며 정확히 떠올랐던 생각임
    “들어오는 연결 시도 이벤트를 구독하는 API 호출은 없다. 괜찮다. 우리가 직접 이벤트를 만들면 된다. WireGuard 연결 요청은 패킷이고 쉽게 식별 가능하므로, BPF 필터와 패킷 소켓으로 효율적으로 낚아챌 수 있다”는 아이디어도 좋음
    들어오는 개시 메시지를 받으면 flyctl이 쓰는 임시 소스 포트까지 포함한 원하는 연결의 4-튜플 주소가 생기고, 우리가 initiator이고 flyctl이 responder인 것처럼 피어를 설치한다는데, 이게 NAT 뒤에서도 동작하는지 궁금함

    • 동작함. UDP NAT는 4-튜플만 알기 때문임. 예를 들어 {wggwd.fly.io, 12345, clientIP, 23456} 같은 형태임
      새 “initiator” UDP 패킷이든, 나가는 개시 메시지에 대한 응답이든, 경로상의 UDP NAT에게는 정확히 똑같이 보임
      판단 근거가 4-튜플뿐이고, 그 4-튜플이 같기 때문임
    • 패킷이 같은 IP/포트로 돌아가고 같은 IP/포트에서 생성되면 NAT를 통과해서 동작함