- 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 사용을 줄였고, 예를 들어 내부
flydAPI는 NATS 기반에서 HTTP 기반으로 바뀜 - NATS 사용을 줄이면서 WireGuard 게이트웨이는 개선됐지만 충분하지 않았음
- Fly.io는 내부에서 NATS 사용을 줄였고, 예를 들어 내부
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] = 1BPF 필터로 들어오는 연결을 잡음
- Fly.io는
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 연결을 시작하며, 프로토콜은 서버와 클라이언트 역할에 크게 의존하지 않음 - 새 연결은 설치 가능한 속도에 가깝게 성립됨
- 들어오는 initiation 메시지에서
운영 적용 결과
- 이 방식은 몇 주 동안 프로덕션에서 실행됨
- 게이트웨이별로 수천 개에서 수십만 개에 이르던 오래된 WireGuard 피어 수가 거의 0에 가까워짐
- 게이트웨이가 보유해야 하는 상태가 줄어듦
- 피어 설정이 더 빨라짐
- 재부팅 시 사용되지 않는 피어를 커널에 다시 로드할 필요가 줄어듦