networking Tailscale WireGuard MikroTik Firewall

VPN은 붙어 있는데 앱만 못 붙었다: 범인은 라우터의 10초였습니다

폰에서 집 맥의 개발 에이전트를 이어 보는데 2분마다 연결이 죽었습니다. MTU도 절전도 방화벽도 아니었고, 집 라우터가 조용한 UDP 흐름을 10초 만에 잊어버리는 것이 원인이었습니다. 진단 중에 측정 도구가 두 번 거짓 신호를 준 이야기까지 함께 적습니다.

2분 뒤에 죽는 연결

집에 둔 맥 한 대가 코딩 에이전트를 띄워 두고 TCP 6768 포트에서 접속을 기다립니다. 밖에서는 아이폰 앱이 그 포트에 붙어서, 에이전트가 물어보면 답하고 결과를 봅니다. 폰과 맥은 Tailscale로 묶여 있습니다. 내 기기들끼리만 통하는 사설망을 만들어 주는 도구입니다.

LTE에서 이 연결이 계속 죽었습니다.

시각무슨 일이 있었나
13:17:04연결 성공. 인증까지 831ms
13:19:10앱이 연결을 포기. 확인 신호 3개를 연속으로 놓쳤고, 마지막 통신이 44초 전
그 뒤재연결 시도가 전부 같은 모양으로 실패. No TCP/WS handshake within 12s

붙고 나서 2분이 조금 넘으면 죽고, 그다음부터는 아예 안 붙습니다. 며칠 전에는 서버 쪽이 먼저 close code 1001(Going Away)로 끊고 15분쯤 같은 증상이 이어졌습니다.

회피법은 있었습니다. 아이폰에서 Tailscale 토글을 껐다 켜면 바로 붙습니다. 그리고 메시지 하나 보내고 잠시 두면 다시 죽습니다.

여기서 판단을 한 번 잘못했습니다. Tailscale 앱을 열어 보면 맥이 계속 online으로 보였습니다. 그래서 “VPN은 멀쩡한데 앱만 이상하다”로 읽고 앱 쪽을 먼저 뒤졌습니다. 그 online 표시가 무엇을 확인한 값인지 따지지 않은 것이 첫 실수입니다.

먼저, 이 글에 나오는 것들

네트워크 용어 네 개만 짚고 갑니다. 아는 분은 건너뛰셔도 됩니다.

NAT와 공유기의 기억. 집 안 기기들은 192.168.1.10 같은 사설 주소를 쓰고, 밖으로 나갈 때는 공유기가 자기 공인 주소로 바꿔 보냅니다. 답장이 돌아오면 누구에게 줄지 알아야 하므로, 공유기는 “방금 이 기기가 저기로 나갔다”를 표에 적어 둡니다. 이 표를 연결 추적(connection tracking, 줄여서 conntrack)이라고 합니다. 이 글의 범인이 여기 있습니다.

직통과 우회. Tailscale은 두 기기를 되도록 직통(direct)으로 잇습니다. 폰이 보낸 UDP 패킷이 집 공유기까지 바로 갑니다. 직통이 안 되면 중계 서버(DERP)를 거치는 우회로로 갑니다. 느리지만 확실합니다.

핸드셰이크. Tailscale의 암호 터널은 WireGuard입니다. 통신을 시작하려면 양쪽이 열쇠를 맞추는 인사를 먼저 합니다. 이 인사가 끝나야 세션이 삽니다. 한동안 조용하면 세션은 정리되고, 다음에 다시 인사해야 합니다.

두 개의 연결. 이 글에서 “끊겼다”는 말이 두 층에서 나옵니다. 헷갈리지 않게 그림으로 둡니다.

flowchart LR
app["아이폰 앱"]
ts1["폰의 Tailscale"]
router["집 라우터<br>(NAT · 방화벽)"]
ts2["맥의 Tailscale"]
host["에이전트 호스트<br>(TCP 6768)"]
app --> ts1
ts1 -- "(A) UDP 41641<br>암호 터널" --> router
router --> ts2
ts2 -- "(B) WebSocket" --> host

(B)가 앱이 보는 연결이고, (A)는 그 아래를 흐르는 VPN 경로입니다. 증상은 (B)에서 나왔고 원인은 (A)에 있었습니다.

빠르게 지운 세 가지

그럴듯했지만 측정하고 접은 가설들입니다.

가설왜 그럴듯했나접은 근거
LTE 경로의 MTU작은 인사 패킷은 통과하고 큰 응답만 잘린다면 딱 이 증상입니다tailscale ping --size로 0·800·1000·1100·1200·1240바이트를 각 3회 보냈고 전부 응답. 터널 MTU는 1280
맥의 절전뚜껑 닫힌 맥이 잠들면 소켓이 죽습니다pmset -g가 SleepDisabled 1, caffeinate가 356시간째 유지, 최근 sleep/wake 이벤트 0건
맥의 방화벽·리스너포트가 안 열려 있으면 같은 증상입니다방화벽 꺼짐, lsof -iTCP:6768 -sTCP:LISTEN이 모든 인터페이스에서 대기 중

세 개를 지우고 나니 남은 것은 경로였습니다. 그런데 경로를 재는 도구가 두 번 거짓말을 했습니다.

함정 하나: 손실 100%인데 정상이었습니다

아이폰의 Tailscale 주소로 평범한 ping을 보내면 이렇게 나옵니다.

Terminal window
$ ping -c 5 100.64.0.2
--- 100.64.0.2 ping statistics ---
5 packets transmitted, 0 packets received, 100.0% packet loss

MTU를 의심하던 중이었으니 “56바이트짜리도 못 간다”로 읽기 딱 좋았습니다. 실제 이유는 다릅니다. iOS의 Tailscale은 앱 안에서 도는 사용자 공간 네트워크 스택이라 일반 ICMP 요청에 응답하지 않습니다. 맥이나 리눅스 peer는 응답하니까 같은 명령이 상대에 따라 다르게 나옵니다.

Tailscale이 자체 핑을 따로 주는 이유가 이것입니다.

Terminal window
$ tailscale ping -c 3 --size 1240 --until-direct=false 100.64.0.2
pong from iphone (100.64.0.2) via 198.51.100.20:41641 in 1.028s
pong from iphone (100.64.0.2) via 198.51.100.20:41641 in 81ms
pong from iphone (100.64.0.2) via 198.51.100.20:41641 in 125ms

1240바이트까지 문제없이 오갑니다. MTU 가설은 여기서 끝났습니다.

이 출력에 덤이 하나 붙어 있습니다. 첫 응답만 1초가 넘고 두 번째부터 81ms입니다. 잠들어 있던 폰의 무선 모듈이 깨는 데 1초쯤 걸린다는 뜻이고, 이 1초가 나중에 다시 등장합니다.

여기서 남길 교훈은 기준선입니다. 손실 0%가 정상인지 100%가 정상인지는 측정을 시작하기 전에 한 번 재 봐야 알 수 있습니다. 문제가 없을 때 이 ping을 한 번만 돌려 봤어도 100% 손실이 원래 그렇다는 것을 알았을 겁니다.

함정 둘: 핑을 보내는데도 세션이 죽었습니다

경로가 유휴로 식는 것이 의심스러워서, 25초마다 폰으로 tailscale ping을 한 번씩 보내는 스크립트를 깔았습니다. 경로를 상온으로 유지하겠다는 의도였습니다.

상태를 24번 찍어 보니 peer의 직통 주소(CurAddr)는 24번 모두 채워져 있었습니다. 성공처럼 보였습니다. 그런데 같은 출력의 다른 필드를 보니 이런 샘플이 섞여 있었습니다.

Terminal window
$ tailscale status --json | jq '.Peer[] | select(.OS=="iOS")
| {CurAddr, Relay, Active, LastHandshake, RxBytes, TxBytes}'
{
"CurAddr": "198.51.100.20:41641",
"Relay": "tok",
"Active": true,
"LastHandshake": "0001-01-01T00:00:00Z", ← 세션이 없습니다
"RxBytes": 0,
"TxBytes": 0
}

LastHandshake가 0001-01-01입니다. 핸드셰이크 기록이 아예 없다는 뜻이고, 암호 터널 세션이 엔진에서 빠졌다는 신호입니다. 주소는 멀쩡한데 터널은 죽어 있었습니다.

원인은 제가 보낸 핑의 종류였습니다. tailscale ping의 기본값은 disco 패킷입니다. 상대 주소를 찾고 확인하는 제어용 패킷이라 주소 탐색 결과만 갱신하고, 암호 터널 안으로는 아무것도 흘려보내지 않습니다. 터널 입장에서는 여전히 조용한 상태입니다.

터널을 실제로 지나는 핑으로 바꾸자 같은 자리에서 세션이 살아났습니다.

Terminal window
tailscale ping --tsmp -c 1 100.64.0.2 # 143ms, 터널 경유
tailscale ping --icmp -c 1 100.64.0.2 # 46ms, 터널 경유

핸드셰이크 시각이 갱신되고 RxBytes 220, TxBytes 404가 붙었습니다. 유지 스크립트는 tsmp를 먼저 쓰고, 실패하면 icmp, 마지막으로 disco로 내려가게 고쳤습니다.

“살아 있음”을 한 필드로 판정하면 이런 것을 놓칩니다. 주소가 있다는 것과 터널이 산다는 것은 다른 사실이고, 제가 보낸 핑은 앞의 것만 갱신하고 있었습니다.

진짜 사슬

집 라우터는 MikroTik hEX S, RouterOS 6.49.10입니다. 라우터에서 연결 추적 설정을 읽고 나서 그림이 맞춰졌습니다.

/ip firewall connection tracking print
udp-timeout: 10s
udp-stream-timeout: 3m

udp-timeout이 10초입니다. RouterOS 기본값이고, 조용한 UDP 흐름을 10초 만에 표에서 지웁니다. 다만 모든 흐름이 그런 것은 아닙니다. 양쪽이 서로 패킷을 주고받아 왕복이 성립한 흐름은 udp-stream-timeout인 3분을 받습니다. 아직 한 방향만 흐른 새 흐름이 10초짜리입니다.

이제 다섯 단계가 한 줄로 이어집니다.

  1. 폰이 백그라운드로 가거나 대화가 멈추면 30초에서 1분 동안 아무 트래픽이 없습니다.
  2. 라우터가 그 UDP 흐름을 표에서 지웁니다.
  3. 라우터 방화벽에 drop all from WAN not DSTNATed 규칙이 있고 조건이 connection-state=new입니다. 표에서 지워진 뒤 폰이 보내는 패킷은 “처음 보는 연결”로 분류되고, UDP 41641번을 안으로 넘기는 포트 포워딩 규칙이 없었으므로 이 규칙이 버립니다.
  4. Tailscale 데몬도 조용한 peer를 정리합니다. 1분이나 2분이면 직통 주소를 비우고, 더 지나면 암호 터널 세션까지 뺍니다.
  5. 폰이 다시 붙으려면 NAT 통과와 핸드셰이크를 처음부터 해야 합니다. 앱이 허용한 시간은 12초입니다. 여기에 무선 모듈이 깨는 1초가 겹칩니다.

그래서 맥이 먼저 보내는 방향은 늘 되고, 폰이 먼저 붙는 방향만 실패했습니다. 맥이 먼저 보내면 그 패킷이 나가면서 표에 항목을 새로 만들고, 폰의 답장은 그 표에 걸려 정상적으로 들어옵니다. 폰이 먼저 보내면 들어올 문이 없습니다.

토글을 껐다 켜면 붙은 이유도 같습니다. 폰이 경로를 처음부터 다시 뚫으니 그 순간에는 표가 새로 생깁니다. 그리고 조용해지면 다시 식습니다.

서버가 1001로 끊은 것도 같은 사슬의 다른 얼굴입니다. 호스트의 모바일 소켓 규약이 ping 10초, liveness 25초입니다. 앱이 백그라운드에서 25초 넘게 조용하면 서버는 규칙대로 닫습니다. 서버는 규약대로 동작했습니다.

우회로가 못 살린 이유

여기서 Tailscale을 아는 분은 막힐 겁니다. 직통이 안 되면 중계 서버로 우회하는 것이 Tailscale 설계의 핵심인데, 이번에는 그 경로가 구제하지 못했습니다.

속도 때문은 아닙니다. 같은 회선에서 재 보면 이렇습니다.

Terminal window
$ tailscale netcheck
* UDP: true
* MappingVariesByDestIP: false
* PortMapping:
* Nearest DERP: Tokyo
* DERP latency:
- tok: 55.9ms (Tokyo)
- sin: 99.5ms (Singapore)

가장 가까운 중계 서버가 55.9ms입니다. 12초 예산에 비하면 무시할 수 있는 값이라, 우회로가 느려서 못 붙은 것은 아닙니다.

남는 설명은 콜드 스타트 전체 시간입니다. 폰이 백그라운드에서 깨어날 때는 앱과 함께 Tailscale 확장도 재개되어야 합니다. 중계 서버에 다시 붙고, 노드 정보를 갱신하고, 암호 터널 인사를 새로 하고, 그다음에야 앱의 WebSocket이 올라갑니다. 직통 경로가 상온이면 이 과정이 전부 생략됩니다. 그래서 이번 수정은 우회로를 손대지 않고 직통을 식지 않게 두는 쪽을 택했습니다.

정직하게 남길 것이 하나 있습니다. 실패한 순간의 Relay 필드 값을 기록해 두지 않았습니다. 폰이 중계 경로로 붙으려다 12초를 넘긴 것인지, 아니면 중계 연결 자체가 아직 복구 전이었는지는 구분하지 못했습니다. 다음에 같은 증상을 만나면 실패 시점의 tailscale status --json을 먼저 남길 생각입니다.

netcheck 출력에 하나 더 눈여겨볼 곳이 있습니다. PortMapping이 비어 있습니다. UPnP나 NAT-PMP처럼 공유기에 “이 포트 좀 열어 줘”라고 요청하는 자동 수단이 이 라우터에는 없다는 뜻입니다. Tailscale이 스스로 길을 뚫을 방법이 없었으니, 아래처럼 손으로 열어 주는 것이 남은 선택지였습니다.

고친 것

맥 쪽: 경로를 상온으로 유지

25초마다 온라인 상태인 iOS peer에게 터널 경유 핑을 한 번 보내는 LaunchAgent를 등록했습니다. tsmp를 먼저 시도하고, 실패하면 icmp, 그다음 disco로 내려갑니다.

25초 간격이 10초 타임아웃보다 긴데 왜 통하는지 궁금하실 겁니다. 왕복이 성립한 흐름은 라우터가 3분짜리로 승격시키기 때문입니다. 첫 왕복만 성립하면 그 뒤로는 25초 간격으로 충분합니다. 더 보수적으로 가려면 8초로 줄이면 됩니다.

라우터 쪽: 세 줄

/ip firewall connection tracking set udp-timeout=1m
/ip dhcp-server lease make-static [find address=192.168.1.10]
/ip firewall nat add chain=dstnat action=dst-nat to-addresses=192.168.1.10 to-ports=41641 \
protocol=udp in-interface-list=WAN dst-port=41641 comment="tailscale direct"

세 줄이 서로 다른 구멍을 막습니다. 하나로 되지 않는지 궁금하실 텐데, 각자 막는 구간이 다릅니다.

줄막는 상황
udp-timeout=1m흐름이 처음 생겨서 아직 왕복이 성립하기 전의 짧은 침묵. 이 구간은 3분 승격을 못 받아 10초 창에 그대로 노출됩니다
리스 고정포트 포워딩이 IP를 가리키는데 그 맥의 주소가 동적이었습니다. 임대 시간이 10분이라 언젠가 규칙이 빗나갑니다
포트 포워딩유지 핑이 멈췄거나 맥이 재부팅된 뒤처럼, 표가 이미 비어 있는 상태에서 폰이 먼저 붙는 경우

포트를 하나 여는 것이 마음에 걸릴 수 있습니다. WireGuard는 올바른 키로 서명되지 않은 패킷에 아무 응답도 하지 않고 조용히 버립니다. 포트 스캐너에게는 닫힌 포트와 구분되지 않습니다. 공유기 관리 화면을 여는 것과는 성격이 다릅니다.

되돌릴 때는 set udp-timeout=10s, 리스를 다시 동적으로, /ip firewall nat remove [find comment~"tailscale"] 입니다.

검증

기준고치기 전고친 뒤
peer 직통 주소 유지24샘플 중 8샘플에서 빔42샘플 전부 유지
암호 터널 세션disco 유지 중에도 핸드셰이크가 0으로 떨어짐18샘플 전부 유지, 0인 샘플 없음
라우터 UDP 수명10초1분
외부에서 41641 도달규칙 없음, 방화벽이 드롭외부 망에서 보낸 3패킷이 카운터에 3건

마지막 줄에 덧붙일 것이 있습니다. 규칙을 넣고 몇 분 뒤 카운터를 봤더니, 제가 시험 패킷을 보내기도 전에 이미 2패킷이 잡혀 있었습니다. 폰이 그동안 계속 직통으로 붙으려 했고 그 패킷이 방화벽에서 사라지고 있었다는 증거입니다. 진단을 사후에 확인해 준 관측입니다.

남은 위험

포트 포워딩이 맥의 IP를 가리키는데, 그 맥의 Wi-Fi MAC 주소가 고정된 하드웨어 값이 아닌 사설 주소입니다. iOS와 macOS가 추적을 막으려고 넣은 기능인데, 주소가 회전하면 DHCP 리스가 깨지고 규칙이 빈 IP를 가리킵니다. 이 위험은 그대로 남아 있습니다. ifconfig en0 | grep ether 값이 리스에 적힌 MAC과 같은지 보면 확인됩니다.

집 Wi-Fi에서는 같은 증상을 재현하지 않았습니다. 다만 기전대로라면 폰이 집 Wi-Fi에 있을 때는 맥과 같은 랜에 있으므로 라우터의 WAN 방화벽을 거치지 않습니다. 증상이 없는 편이 설명과 맞습니다. 밖의 Wi-Fi에서는 어땠는지 측정하지 않았습니다.

가져갈 것 두 가지

하나. “살아 있음”은 한 필드로 판정할 수 없습니다. VPN 앱의 online, peer의 직통 주소, 핸드셰이크 시각, 실제 송수신 바이트가 전부 다른 사실입니다. 이번에는 앞의 두 개가 초록인 채로 뒤의 두 개가 죽어 있었습니다. 상태 표시를 볼 때는 그 값이 무엇을 확인한 결과인지부터 물어야 합니다.

둘. 층마다 타임아웃 기본값이 서로 맞춰져 있지 않습니다. 이번 사슬에 걸린 숫자만 다섯입니다.

숫자어디서 정한 값인가
10초라우터의 유휴 UDP 수명 (RouterOS 기본값)
3분라우터의 왕복 성립 UDP 수명
1분에서 2분Tailscale이 조용한 peer의 직통 주소를 비우는 시간
25초호스트가 모바일 소켓을 닫는 기준
12초앱이 재연결에 허용한 시간

어느 하나도 잘못 설정된 값이 아닙니다. 각 층의 개발자가 자기 층에서 합리적으로 고른 값이고, 문제는 그 값들이 서로를 모른다는 데 있습니다. 한쪽이 10초에 지우는데 다른 쪽이 12초를 기다리면, 그 사이에서 증상이 불규칙하게 나타납니다. 여러 층을 거치는 연결이 “가끔” 죽는다면 각 층의 타임아웃을 한 줄에 늘어놓고 비교해 보시길 권합니다.

관련 콘텐츠

댓글