DEV Community

Artistrator
Artistrator

Posted on

AWS 클라우드 환경에서 SSH 터널 서버 안정성 확보하기 — 다층 방어 설계

문제 상황

MSA 환경에서 온프레미스 DB에 접근하기 위해 리버스 SSH 터널을 사용하고 있었다. 구조는 이렇다:

[온프레미스 PC] ──리버스 터널──► [EC2 SSH 서버] ◄──포워드 터널── [앱 서버 (NestJS)]
Enter fullscreen mode Exit fullscreen mode

온프레미스 DB에 방화벽 인바운드가 차단되어 있어서, 현장 미니 PC가 먼저 밖으로 SSH 연결을 열어두고(리버스 터널), 앱 서버가 그 통로를 통해 DB에 접근하는 구조다.

그런데 EC2 터널 서버가 주기적으로 CPU 급상승 후 넉다운되는 현상이 반복되었다. 연결성 검사(health check)가 실패 상태로 남아, 수동 복구가 필요한 상황이 계속됐다.

원인 분석: 3개 레이어의 복합 문제

Claude Code로 코드베이스 전체(터널 관리, ETL 스케줄링, PM2 설정, BullMQ 큐)를 병렬로 분석한 결과, 한 곳이 아니라 3개 레이어에 걸친 복합 문제였다.

Layer 1 — 앱 서버: 백오프 없는 재연결 폭풍

터널 매니저가 10초마다 헬스체크를 하고, 실패하면 아무 제어 없이 즉시 재연결을 시도하고 있었다:

// 문제: setInterval로 10초마다 무조건 실행, 백오프 없음
startHealthCheck(config: TunnelConfig, intervalMs = 10000) {
  const interval = setInterval(async () => {
    await this.checkAndReconnectTunnel(config);
  }, intervalMs);
}

private async checkAndReconnectTunnel(config: TunnelConfig) {
  if (await this.isPortReachable(config.localPort)) return;
  // 실패 → 바로 새 SSH 연결 → 터널 서버에 sshd fork
  await this.createTunnel(config);
}
Enter fullscreen mode Exit fullscreen mode

거기에 PM2로 같은 서비스를 2개 인스턴스로 띄우고 있어서, 동일한 터널에 대해 사실상 5초에 한 번꼴로 재연결을 시도했다. 각 재연결은 터널 서버에 새로운 sshd 프로세스를 fork한다.

Layer 2 — SSH 서버: 좀비 sshd 방치

sshd_configClientAliveInterval 설정이 없었다. 앱 서버가 client.end()로 SSH 세션을 종료해도, TCP 연결이 이미 끊어진 상태면 그 메시지가 원격 sshd에 도달하지 않는다. 결과적으로 좀비 sshd 프로세스가 OS 기본 TCP keepalive(2시간) 동안 살아남는다.

Layer 3 — 온프레미스 네트워크 불안정

온프레미스 미니 PC의 리버스 터널이 끊기면 연쇄 반응이 시작된다:

트래픽 감소 → NAT 테이블 만료 → 리버스 터널 끊김
  → 앱 서버 헬스체크 "포트 도달 불가"
  → 10초마다 재연결 (2인스턴스 x N개 테넌트)
  → 리버스 터널이 없으므로 전부 실패
  → 하지만 sshd fork는 이미 생성됨
  → 좀비 누적 → CPU 급상승 → 넉다운
Enter fullscreen mode Exit fullscreen mode

특히 트래픽이 적은 시간대에 NAT 매핑이 만료되면서 이 사이클이 가속된다.

해결: 코드만으론 부족하다

"코드만 고치면 되겠지?"라고 생각했지만, 3개 레이어를 전부 손봐야 했다.

1. 앱 서버 코드 — 재연결 제어

setInterval → setTimeout 체인: 이전 체크가 끝나야 다음이 스케줄되도록 변경했다. setInterval은 콜백 실행 시간과 무관하게 다음 호출이 잡히기 때문에, 재연결이 오래 걸리면 체크가 겹쳐서 동시 SSH 연결이 발생할 수 있었다.

// 개선: setTimeout 체인 + 완료 후 다음 스케줄
startHealthCheck(config: TunnelConfig, intervalMs = 30000) {
  const scheduleNext = () => {
    const timeout = setTimeout(async () => {
      try {
        await this.checkAndReconnectTunnel(config);
      } catch (error) {
        this.logger.error(`Health check error: ${error.message}`);
      }
      // 현재 체크가 완료된 후에만 다음 체크 스케줄
      if (this.healthCheckIntervals.has(name)) {
        scheduleNext();
      }
    }, intervalMs);
    this.healthCheckIntervals.set(name, timeout);
  };
  scheduleNext();
}
Enter fullscreen mode Exit fullscreen mode

Per-name 락: 같은 터널에 대한 동시 생성 시도를 차단했다. 2개 인스턴스가 동시에 같은 포트의 터널을 만들려 할 때 하나만 실행되고 나머지는 대기 후 리턴한다.

async createTunnel(config: TunnelConfig) {
  const { name } = config;

  // 동일 터널의 동시 생성 방지
  const existingLock = this.locks.get(name);
  if (existingLock) {
    await existingLock.catch(() => {});
    return;  // 다른 호출이 이미 생성 완료
  }

  const lockPromise = this.createTunnelInternal(config);
  this.locks.set(name, lockPromise);
  try {
    await lockPromise;
  } finally {
    this.locks.delete(name);
  }
}
Enter fullscreen mode Exit fullscreen mode

분산 크론: ETL 스케줄러의 @Cron@DistributedCron으로 변경하여, 2개 인스턴스 중 하나만 ETL을 실행하도록 했다. 이전에는 두 인스턴스 모두 동일한 ETL을 실행하며 동시에 터널 연결을 시도하고 있었다.

// Before: 2개 인스턴스 모두 실행
@Cron("0 6 * * *", { name: "morning-etl", timeZone: "Asia/Seoul" })

// After: Redis 락으로 하나만 실행
@DistributedCron("0 6 * * *", { name: "morning-etl", timeZone: "Asia/Seoul" })
Enter fullscreen mode Exit fullscreen mode

2. EC2 터널 서버 — 좀비 sshd 자동 정리 스크립트

sshd 설정 강화(ClientAliveInterval)와 별개로, 2중 안전장치로 좀비 sshd를 직접 탐지해서 kill하는 스크립트를 작성했다.

단순히 "오래된 프로세스를 죽이는" 것이 아니라, 실제 I/O가 있는지 측정해서 판단한다. 리버스 터널이 정상 작동 중인 세션은 절대 건드리지 않아야 하기 때문이다.

#!/bin/bash
# cleanup-idle-tunnels.sh
set -euo pipefail

SAMPLE_SEC=10       # I/O 측정 시간 (초)
MIN_AGE_SEC=3600    # 1시간 미만 세션은 스킵

# sshd 메인 프로세스의 자식들만 대상
MAIN_SSHD_PID=$(pgrep -o -x sshd)
CHILD_PIDS=$(pgrep -P "$MAIN_SSHD_PID" -x sshd)
Enter fullscreen mode Exit fullscreen mode

핵심 판별 로직은 /proc/{pid}/net/dev에서 네트워크 바이트를 10초 간격으로 두 번 읽어서 delta가 0인지 확인하는 것이다:

get_net_bytes() {
  awk 'NR>2 { rx+=$2; tx+=$10 } END { print rx+tx }' "/proc/$1/net/dev"
}

for PID in $CHILD_PIDS; do
  # 1시간 미만 세션은 보호 (방금 생성된 정상 세션일 수 있음)
  AGE=$((NOW - $(stat -c %Y "/proc/$PID")))
  [[ $AGE -lt $MIN_AGE_SEC ]] && continue

  # 10초간 I/O 측정
  BYTES_BEFORE=$(get_net_bytes "$PID")
  sleep "$SAMPLE_SEC"
  BYTES_AFTER=$(get_net_bytes "$PID")

  if [[ $((BYTES_AFTER - BYTES_BEFORE)) -eq 0 ]]; then
    kill "$PID"           # graceful 종료 시도
    sleep 2
    kill -9 "$PID" 2>/dev/null || true  # 안 죽으면 강제
  fi
done
Enter fullscreen mode Exit fullscreen mode

정리 결과는 Slack으로 자동 리포트된다. CPU, 메모리, 로드 평균과 함께 세션 현황을 알려준다:

[Tunnel Server] Idle 세션 정리 완료
────────────────────
Host: tunnel-server
Time: 2026-03-08 11:00:05 KST

[시스템 리소스]
CPU: 12.3%
Memory: 487MB / 978MB (49.8%)
Load Avg: 0.15 0.10 0.08

[SSH 세션]
Total: 14 | Killed: 6 | Active: 5 | Skipped: 3
Enter fullscreen mode Exit fullscreen mode

하루 2번 cron으로 등록했다:

0 11 * * * /usr/local/bin/cleanup-idle-tunnels.sh >> /var/log/cleanup-idle-tunnels.log 2>&1
0 23 * * * /usr/local/bin/cleanup-idle-tunnels.sh >> /var/log/cleanup-idle-tunnels.log 2>&1
Enter fullscreen mode Exit fullscreen mode

--dry-run 모드도 지원해서, 처음에는 kill 없이 상태만 Slack으로 받아보며 패턴을 관찰한 후 실제 적용했다.

왜 sshd 설정만으로 부족한가?

ClientAliveInterval 30 + ClientAliveCountMax 3을 설정하면 90초 내에 죽은 세션이 정리된다. 하지만 이것은 SSH 프로토콜 레벨 keepalive다. TCP 연결 자체가 half-open 상태(한쪽만 끊어진 상태)일 때는 SSH keepalive 패킷이 OS TCP 스택에서 버퍼링만 되고 실제 전송되지 않을 수 있다. 특히 중간에 NAT 장비가 있으면 이 상황이 빈번하다. 그래서 프로세스 레벨에서 실제 I/O를 측정하는 2중 안전장치가 필요하다.

3. 온프레미스 미니 PC — keepalive 강화

# SSH keepalive 30초로 단축 (NAT 만료 방지)
ssh -R 5555:192.168.x.x:1433 -N \
  -o ServerAliveInterval=30 \    # 60 → 30초
  -o ServerAliveCountMax=3 \
  tunnel-service@ssh-server -p 222
Enter fullscreen mode Exit fullscreen mode

해결 구조 요약

[앱 서버 코드]
  - setInterval → setTimeout 체인 (겹침 방지)
  - 10초 → 30초 헬스체크 주기
  - per-name 락 (동시 터널 생성 차단)
  - @DistributedCron (2인스턴스 중복 실행 방지)

[EC2 터널 서버]
  - sshd_config: ClientAliveInterval 30 (SSH 레벨)
  - sysctl: tcp_keepalive_time 60 (OS TCP 레벨)
  - cleanup-idle-tunnels.sh cron (프로세스 I/O 레벨)
  - Slack 모니터링 알림

[온프레미스 미니 PC]
  - SSH keepalive 60초 → 30초 (NAT 만료 방지)
  - 절전 모드 해제
Enter fullscreen mode Exit fullscreen mode

배운 점

  • "코드만 고치면 된다"는 착각이다. 이 문제는 앱 코드, SSH 서버 설정, 온프레미스 네트워크 3개 레이어가 전부 기여하고 있었다. 어느 하나만 고치면 증상이 완화될 뿐 재발한다.
  • 주기적 장애는 트래픽 패턴과 리소스 생명주기의 교차점을 의심해야 한다. 활성 트래픽이 연결을 유지해주다가, 트래픽이 줄어드는 시간대에 숨어있던 결함이 드러나는 패턴이다.
  • 재연결 로직에는 반드시 백오프가 필요하다. 상대방이 죽었을 때 더 빨리 재연결한다고 살아나지 않는다. 오히려 시체를 더 빨리 쌓을 뿐이다.
  • 방어는 다층으로. SSH 프로토콜 keepalive → OS TCP keepalive → 프로세스 I/O 측정, 각 레이어가 서로 다른 실패 모드를 커버한다.

AI 활용 포인트

Claude Code의 병렬 에이전트로 터널 관리 코드, ETL 스케줄러, PM2 설정, BullMQ 큐, SSH 아키텍처 문서를 동시에 분석했다. 수십 개 파일을 읽으며 상관관계를 파악해야 하는 작업에서, AI가 전체 코드베이스를 빠르게 훑고 "이 setInterval이 여기서 문제를 일으키고, 그 결과가 저 서버에서 좀비 sshd로 나타난다"는 레이어 간 인과관계를 짚어주는 것이 결정적이었다.

Top comments (0)