같은 VpnService 루프에서 두 가지 증상이 나타나고 둘 다 같은 원인으로 몰립니다. 결코 돌아오지 않는 읽기와 ENOBUFS로 실패하는 쓰기입니다. 이것은 하나의 버그가 변형된 것이 아닙니다. 멈춘 읽기는 피어와의 흐름 제어에 대한 불일치이고, 실패한 쓰기는 인터페이스가 받아들이지 않는 패킷입니다. 둘을 하나의 문제로 다루면 전혀 관련 없는 버퍼를 조정하며 하루를 보내게 됩니다. 아래의 구분은 루프의 어느 절반을 먼저 계측할지 정하는 방법입니다.

하나의 루프, 두 개의 독립적인 고장 지점

서비스는 TUN 파일 디스크립터에서 IP 패킷을 읽고 검사한 뒤 같은 디스크립터로 응답을 씁니다. 이 경로에서 깨지는 모든 것은 이 두 호출 중 하나에서 드러나므로 둘 다 터널 고장으로 보고됩니다. 호출의 계약은 다릅니다. 읽기는 커널이 패킷을 넘겨주기를 기다리고, 쓰기는 커널이 거부할 수 있는 패킷을 넘깁니다.

읽기는 생각하기 쉬운 쪽입니다. 아무것도 돌려주지 않는 읽기는 결코 오지 않는 트래픽을 기다리거나 피어가 보내기를 멈췄기 때문에 기다립니다.

멈춘 읽기는 흐름 제어 불일치

TUN 장치 읽기는 패킷이 준비될 때까지 블록합니다. 이것은 설계된 동작이며, 유휴 링크에서 read 안에 멈춰 있는 스레드가 정상으로 보이는 이유입니다. 이것이 멈춤이 되는 것은 자체 TCP 스택이 로컬 앱에서 데이터를 계속 받아들이면서 원격 피어가 더 보낼 수 있을 만큼의 윈도를 알리지 않을 때입니다. 피어는 알려진 윈도를 채우고 ACK와 윈도 갱신을 기다린 뒤 멈춥니다. 그러면 읽기 쪽에 돌려줄 것이 없고, 앱은 열려 있지만 조용한 연결을 보게 됩니다.

흔한 원인은 알려진 윈도를 추적하지 않고 소켓에 쓰는 스택과, 버퍼를 비운 뒤 윈도 갱신을 보내는 것을 잊는 스택입니다. 밖에서 보면 둘은 똑같습니다. 바이트는 나가는데 아무것도 돌아오지 않고 오류도 발생하지 않습니다. 읽기 스레드를 파싱, DNS 해석, 디스크 I/O에서 막아도 관련 없는 이유로 같은 형태의 증상이 생기므로, 흐름 제어를 탓하기 전에 스레드가 정말 read 안에 있는지 확인하세요.

피어의 전송 큐와 피어가 마지막으로 본 윈도를 확인하세요. 윈도가 영이라서 피어가 데이터를 붙잡고 있다면 수정할 곳은 ACK 경로이지 TUN 읽기가 아닙니다.

ENOBUFS는 크기 문제이지 버퍼 부족이 아니다

TUN 인터페이스에서 ENOBUFS를 반환하는 쓰기는 보통 패킷 길이를 거부합니다. 인터페이스에는 MTU가 있고, 그보다 큰 패킷은 큐에 들어갈 수 없으며, 같은 패킷을 다시 시도하면 매번 같은 방식으로 실패합니다. 이 결정성이 단서입니다. 진짜 버퍼 부족은 압력이 줄면 풀리지만, 크기 거부는 얼마나 기다리든 상관하지 않습니다.

TCP에는 바로 이 일을 위한 메커니즘이 있고, 그것이 사람들이 건너뛰는 단계인 MSS 클램프입니다. 사용자 공간에서 세그먼트를 합성할 때 페이로드 크기를 직접 고르는데, 터널의 MTU 대신 로컬 인터페이스의 MTU에서 고르면 터널이 나를 수 없는 세그먼트를 내보냅니다. 둘 중 작은 값으로 제한하세요. 클램프는 TCP만 다루므로 너무 큰 UDP 데이터그램은 코드에서 분할하거나 버려야 합니다.

디버깅하는 동안 쓰는 모든 패킷의 길이를 기록하세요. 실패하는 길이가 답이며, 재시도 사이에 바뀌지 않습니다.

사례를 구분하는 세 가지 측정

두 고장 모두 디버거가 필요하지 않습니다. 세 가지 관찰이면 올바른 절로 안내하기에 충분하고, 다시 빌드하는 것보다 시간이 적게 듭니다.

What you seeWhere to look
Reader parked, peer send queue empty, last advertised window zeroWindow updates in your ACK path
Write fails on one length and succeeds on a smaller oneMSS clamp and tunnel MTU
Both directions look fine, app still sees no dataProtected socket and routing loop

세 번째 줄이 맞다면 루프 자체는 정상이고 문제는 터널이 볼 수 없는 곳에 있습니다. 자체 VPN 트래픽을 터널로 되돌려 보내는 보호되지 않은 전송 소켓이며, 보통 연결이 성공한 뒤 아무것도 전달하지 않는 형태로 나타납니다.

두 경로가 의존하는 배관을 고치세요

인터페이스가 올바르게 구축되기 전에는 어떤 증상도 쫓을 가치가 없습니다. 중요한 builder 호출은 주소, 경로, MTU, 동의 순서입니다.

Builder()
  .addAddress(10.8.0.2, 32)
  .addRoute(0.0.0.0, 0)
  .setMtu(1400)
  .establish()

MTU를 예제가 쓴 값에 두지 말고 터널의 실제 값으로 설정하세요. 나중에 쓰는 모든 것은 그 숫자로 제한되며, 설정한 MTU와 제한하는 MSS의 불일치는 수정 후 쓰기 실패를 다시 불러오는 가장 흔한 방법입니다. 그다음 VPN 서버 자체로 가는 트래픽을 실어 나르는 소켓을 보호하세요. protect가 없으면 그 패킷은 자체 터널로 되돌아가고 연결이 끝나지 않으며, 이는 멈춘 읽기처럼 보이지만 라우팅 루프입니다.

동의는 우회할 수 없습니다. prepare가 null을 반환하지 않고 시작한 서비스는 인터페이스를 얻지 못하고, establish는 예외를 던지는 대신 null을 반환하므로 루프를 시작하기 전에 디스크립터를 확인하세요. 예제를 복사하는 대신 VpnService 참조에서 builder 계약을 확인하세요.

한 번에 한 방향씩 검증하세요

TCP를 건드리기 전에 터널을 통해 UDP 에코를 보내고 패킷 길이를 관찰하세요. 그다음 핸드셰이크를 열고 세그먼트가 계산한 MSS 아래에 머무는지 확인하세요. 연결이 아니라 패킷을 세면 멈춤이 걸린 소켓이 아니라 카운터의 빈틈으로 나타납니다. 쓰기가 몰아친 뒤 읽기가 멈추면 윈도가 용의자이고, 쓰기가 즉시 실패하면 길이입니다.

그다음 장치를 재부팅하고 한 번 반복하세요. 첫 네트워크 변경까지만 동작하는 VpnService는 흐름 제어가 아니라 라우팅 문제입니다.

관련 글