역방향 SSH 터널이 반쯤 죽을 때: ServerAlive 가 8시간 동안 못 끊은 이유
역방향 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=30 과 ServerAliveCountMax=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초마다 끊고 다시 거는 일이 반복됩니다. 그동안 서비스는 띄엄띄엄 됩니다. 회선 자체가 앓는 것은 오리진 쪽에서 고칠 수 없고, 대문이 상태를 안 들고 있으니 다른 망으로 옮기는 것은 쉽습니다. 이번 손실이 한 번으로 끝날지 반복될지는 아직 모릅니다.
또 하나는 이날 장애를 사람이 우연히 상태 페이지를 열어서 알았다는 점입니다. 같은 글자가 붙은 줄이 한꺼번에 빨개지면 알림이 오게 하는 것이 다음 할 일입니다. 감시 장치 셋이 모두 정상을 말하는 상황이 다시 오면, 그때도 잡아낼 것은 바깥에서 실제 응답을 보는 감시뿐입니다.
우리 서비스에 적용할 계획이 있으신가요?
클라우드 구축·배포 운영 살펴보기 →함께 읽기
- Supabase 풀러 계정 이름: 운영이 두 번 죽은 이유환경변수의 정본을 볼트로 옮기는 작업이었습니다. 값을 한곳에 모으고, 거기서 배포처로 밀고, 어긋나면 대조로 잡는 구조입니다. 마지막 단계가 운영이었습니다.
- 무중단 배포의 드레인: 연결을 비우는 시간과 버전이 섞이는 시간배포 버튼을 누르고 얼마 뒤 모니터링에 502 가 몇 개 찍힙니다. 새 버전은 헬스체크를 통과했고, 옛 버전은 로드밸런서에서 뺐습니다. 순서대로 했는데 요청이 끊겼습니다. 같은 시각 브라우저 콘솔에는 ChunkLoadError 가 뜹니다. 방금 받은 페이지가 존재하지 않는 자바스크립트 파일을 달라고 한 것입니다.
- UPS 용량 계산: VA와 W의 차이, 역률과 버티는 시간이 문서는 UPS를 고를 때 필요한 VA와 W의 관계, 역률, 부하 합산과 여유율, 부하에 따른 런타임 변화, UPS 방식과 출력 파형의 차이를 정리합니다.
- 로컬에서는 통과한 Next.js 타입 검사가 Docker 배포에서만 실패하는 이유Next.js 앱에서 로컬 타입 검사는 통과하는데 Docker 배포에서만 같은 오류가 다시 나오면 코드보다 빌드 이미지에 무엇이 들어갔는지 봐야 합니다. 이번 작업에서는 린트, 타입 검사, 프로덕션 빌드를 모두 통과시킨 뒤 배포했는데 원격 빌드만 실패했습니다. 같은 커밋과 같은 잠금 파일을 썼으니 처음에는 환경 차이가 …
- Docker 빌드 컨텍스트가 1.06GB까지 커진 이유Docker 빌드가 느리면 패키지 설치나 애플리케이션 컴파일부터 보게 됩니다. 저도 타입 검사 실패를 고치는 데 집중하다가 그보다 앞선 load build context 단계에서 40초가 넘게 쓰이고 있다는 사실을 뒤늦게 봤습니다. 실패 원인과는 별개였지만, 매 배포마다 반복되는 낭비였습니다.