RSS듀오랩스
웹 인프라

역방향 SSH 터널이 반쯤 죽을 때: ServerAlive 가 8시간 동안 못 끊은 이유

작성자
듀오랩스 대표·6분 읽기

역방향 SSH 터널로 오리진을 내보내는 구성에서, 터널 너머의 서비스들이 한꺼번에 「응답 없음」이 된 날의 기록입니다. 프로세스도 소켓도 살아 있다고 말하는데 서비스만 죽어 있었고, 원인은 제가 「죽은 연결은 90초 안에 정리된다」고 믿고 있던 설정에 있었습니다.

빨간 줄이 곧 터널의 포트 목록

구성은 흔한 꼴입니다. 서울 리전의 작은 VPS 하나가 대문 역할을 합니다. 공개 도메인은 전부 그 서버를 가리키고, Caddy 가 TLS 를 받은 뒤 오리진 서버가 걸어 둔 역방향 SSH 터널의 포트로 넘깁니다. 오리진에서 ssh -N -R 2004:127.0.0.1:2004 … 를 포트 수만큼 걸어 두는 식입니다.

상태 페이지에서 빨개진 줄을 그 -R 목록과 대 보니 정확히 겹쳤습니다. 반대로 대문 서버가 직접 답하는 /healthz 는 초록이었습니다. 앞길은 살아 있고 터널 너머만 안 보이는 상황이라는 뜻입니다. 이 한 줄이 없었다면 「앱이 죽었나」부터 뒤졌을 텐데, 상태 페이지에 대문의 자기 응답을 한 줄 넣어 둔 것이 이날 시간을 가장 많이 아꼈습니다.

오리진에 들어가 보니 컨테이너는 전부 Up 이었고 로컬 포트도 정상으로 답했습니다. 터널 프로세스도 살아 있었습니다. 8시간 20분째였습니다.

ESTABLISHED 인데 Send-Q 만 쌓이는 소켓

터널 소켓을 보니 상태는 ESTABLISHED 였습니다. 그런데 Send-Q 가 12KB 였고, 2분 뒤 다시 보니 34KB 였습니다. 보내는 쪽은 계속 쓰는데 상대가 ACK 를 주지 않는 연결입니다. 프로세스 입장에서는 연결이 끊긴 적이 없고, 커널 입장에서는 재전송 중이고, 사용자 입장에서는 죽어 있습니다.

터널 로그에는 새벽 04:30 에 대문 서버가 내려갔다가 올라온 흔적이 있었습니다. 대문 서버에 들어가 확인하니 unattended-upgrades 가 전날 libc6 패치를 깔면서 「Reboot scheduled for 04:30 KST」를 걸어 둔 것이었습니다. 이건 설계된 동작입니다. 대문은 상태를 안 들고 있어서 재부팅해도 되고, 터널은 오리진이 다시 걸면 됩니다. 실제로 사흘 전 같은 시각의 커널 패치 재부팅 때는 아무 일도 없었습니다.

그래서 처음에는 「재부팅 직후 다시 붙은 세션이 어딘가에서 꼬였다」로 봤습니다. launchctl kickstart -k 로 터널을 다시 띄우자 전부 돌아왔고, 거기서 끝난 줄 알았습니다.

3분 만에 다시 108KB

몇 분 뒤 다시 느려졌습니다. 새로 만든 소켓을 보니 Send-Q 가 108KB 였습니다. 재부팅 뒤의 잔재가 아니라 지금 이 순간 회선이 데이터를 잃고 있다는 뜻입니다. 재보니 오리진에서 대문으로 ping 이 20% 손실, 제 작업 기계에서는 60% 손실이었습니다. 대문 서버 안에서 바깥 DNS 서버로 ping 을 쳐도 20% 가 빠졌습니다. 오리진 쪽 회선은 다른 목적지로는 0% 였습니다. 대문 VPS 가 놓인 망이 잠깐 앓고 있던 것이고, 20분쯤 지나자 저절로 0% 로 돌아왔습니다.

재부팅은 원인이 아니었습니다. 우연히 같은 날 새벽에 있었을 뿐이고, 진짜 원인은 대문 쪽 회선의 간헐적 패킷 손실이었습니다. 다만 그 손실이 8시간짜리 장애가 된 데에는 제 설정 하나가 있었습니다.

ServerAliveInterval 30 × 3 이 못 끊는 연결

터널에는 ServerAliveInterval=30ServerAliveCountMax=3 이 걸려 있었습니다. 30초마다 응답 요청을 보내고 세 번 연속 답이 없으면 끊는다는 뜻이라, 죽은 연결은 90초 안에 정리된다고 믿고 있었습니다. 8시간을 버틴 세션이 그 믿음을 깼습니다.

이유는 「세 번 연속」에 있습니다. 회선이 완전히 끊긴 게 아니라 패킷의 20~60% 를 잃는 상태면, 응답 요청 셋 중 하나는 돌아옵니다. 하나만 돌아와도 카운터는 0 으로 리셋됩니다. ssh 는 연결이 살아 있다고 판단하고, TCP 는 계속 재전송하고, launchd 의 KeepAlive 는 프로세스가 살아 있으니 재시작할 이유가 없습니다. 감시 장치 셋이 전부 「정상」을 보고하는 채로 서비스만 죽어 있는 구도입니다.

완전히 끊긴 연결보다 반쯤 산 연결이 더 오래 갑니다. 이 점을 설정할 때 생각하지 못했습니다.

30초 안에 끊고 다시 걸게 바꾼 것

이날 실제로 서비스를 살린 동작은 재접속이었습니다. 같은 손실 구간에서도 새 세션은 몇 분은 제대로 흘렀습니다. TCP 상태가 초기화되고 혼잡 창이 다시 잡히기 때문입니다. 그렇다면 고칠 것은 「죽은 연결을 얼마나 빨리 죽이느냐」입니다.

ServerAliveInterval 30 10
ServerAliveCountMax 3 3
끊기까지 최대 90초 30초
TCPKeepAlive 기본값에 맡김 yes 명시

간격을 10초로 줄이면 손실률이 같아도 30초 안에 세 번 연속 실패할 확률이 훨씬 올라가고, 끊긴 뒤에는 launchd 와 systemd 가 10초 뒤 다시 겁니다. TCPKeepAlive 는 OpenSSH 기본값이 이미 yes 라 실질 변화는 없지만, 다음 사람이 설정 파일만 읽고도 두 층의 keepalive 가 모두 켜져 있음을 알도록 적어 두었습니다. 오리진 둘(macOS launchd, Linux systemd) 모두 같은 값으로 맞췄습니다.

autossh 는 쓰지 않기로 했습니다. autossh 가 하는 일은 별도 에코 채널로 생존을 확인하고 ssh 를 다시 띄우는 것인데, 재시작은 이미 launchd 와 systemd 가 하고 있고, 생존 확인은 ServerAlive 를 조이는 것으로 충분합니다. 의존성을 하나 더 들이는 만큼의 이득이 없었습니다.

남은 것

이 설정으로도 손실이 심한 동안에는 30초마다 끊고 다시 거는 일이 반복됩니다. 그동안 서비스는 띄엄띄엄 됩니다. 회선 자체가 앓는 것은 오리진 쪽에서 고칠 수 없고, 대문이 상태를 안 들고 있으니 다른 망으로 옮기는 것은 쉽습니다. 이번 손실이 한 번으로 끝날지 반복될지는 아직 모릅니다.

또 하나는 이날 장애를 사람이 우연히 상태 페이지를 열어서 알았다는 점입니다. 같은 글자가 붙은 줄이 한꺼번에 빨개지면 알림이 오게 하는 것이 다음 할 일입니다. 감시 장치 셋이 모두 정상을 말하는 상황이 다시 오면, 그때도 잡아낼 것은 바깥에서 실제 응답을 보는 감시뿐입니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.