역방향 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초마다 끊고 다시 거는 일이 반복됩니다. 그동안 서비스는 띄엄띄엄 됩니다. 회선 자체가 앓는 것은 오리진 쪽에서 고칠 수 없고, 대문이 상태를 안 들고 있으니 다른 망으로 옮기는 것은 쉽습니다. 이번 손실이 한 번으로 끝날지 반복될지는 아직 모릅니다.
또 하나는 이날 장애를 사람이 우연히 상태 페이지를 열어서 알았다는 점입니다. 같은 글자가 붙은 줄이 한꺼번에 빨개지면 알림이 오게 하는 것이 다음 할 일입니다. 감시 장치 셋이 모두 정상을 말하는 상황이 다시 오면, 그때도 잡아낼 것은 바깥에서 실제 응답을 보는 감시뿐입니다.
함께 읽기
- TTFB: 속도가 아니라 지연 시간을 재는 지표회선을 두 배로 넓혀도 TTFB 는 거의 그대로입니다. 서버를 한 대 더 붙여도 마찬가지입니다. 그런데 같은 서버를 사용자와 가까운 리전으로 옮기면 절반으로 떨어집니다.
- Vercel 재배포 직후 500: 재배포 탓인지 값 탓인지 가르는 순서운영에 올릴 새 커밋은 없었습니다. main 과 develop 이 같은 커밋이었고, 마지막 운영 배포는 그 커밋이 올라간 지 4분 뒤에 만들어져 있었습니다. 그래도 한 번 다시 올려 두자고 해서 vercel redeploy 로 같은 배포를 다시 만들었습니다. 2분 만에 Ready 가 떴고 도메인에 붙었습니다.
- Cloudflare 존 이전과 R2: 커스텀 도메인이 따라오지 않는 이유이미지가 하나도 안 나온다는 말을 듣고 열어 봤습니다. 이미지 원본을 서빙하는 호스트 세 개가 DNS 에서 통째로 사라져 있었습니다. dig 가 아무것도 돌려주지 않았습니다.
- Vercel DB 통합: 비밀번호를 몰라도 되는 이유하루에 관리형 Postgres 두 개를 배포에 붙였습니다. 하나는 비밀번호를 재설정하고 연결 문자열을 손으로 옮겨 적었고, 다른 하나는 비밀번호를 끝내 보지 않았습니다. 후자가 더 빨랐고, 무엇보다 틀릴 자리가 없었습니다.
- docker builder prune 상한: 왜 남의 캐시까지 깎일까?한 서비스의 개발 배포를 다른 호스트로 옮기는 중이었습니다. 러너를 새로 붙이고, 환경변수 출처를 바꾸고, 이제 워크플로를 켜기만 하면 되는 참이었습니다. 그런데 배포 스크립트 마지막에 있는 한 줄에서 멈췄습니다.