DB 커넥션 풀의 죽은 연결(half-open) — 요청이 15분 멈춘 이유와 방어 세 겹

정기창·

서버는 이미 닫았는데 클라이언트는 열려 있다고 믿는 연결을 흔히 half-open(죽은) 연결이라고 부릅니다. 이런 연결이 커넥션 풀에 남아 있으면, 그 연결을 집은 요청은 응답 없이 15분 가까이 멈췄다가 read ETIMEDOUT 으로 끝납니다. 이 글은 그 원인을 추적하고 방어를 세 겹으로 쌓은 과정의 기록입니다.

환경은 Ubuntu 24.04 호스트 위의 Docker 컨테이너(Node.js 24, mysql2 3.23)와 관리형 MySQL 8.4 입니다. 컨테이너는 기본 브리지 네트워크를 쓰므로 바깥으로 나가는 연결은 NAT(MASQUERADE)를 거칩니다. TCP 와 커넥션 풀의 기본 개념은 알고 계신다고 가정하고 썼습니다.

증상: 요청이 정확히 15분 뒤에 실패했습니다

어느 날 오후, 인증 갱신 요청 몇 건이 500 으로 끝났습니다. 로그에는 Error: read ETIMEDOUT 한 줄뿐이었고 데이터베이스는 멀쩡했습니다. 다른 요청은 모두 정상이었습니다.

이상한 것은 시간이었습니다. 요청이 들어온 시각과 에러가 난 시각을 빼 보니 매번 15분 남짓이었습니다. 애플리케이션 어디에도 15분이라는 설정은 없었습니다. 그렇다면 애플리케이션 밖의 무언가가 정해진 시간만큼 기다리다 포기했다는 뜻입니다. 돌이켜 생각해보면 이 숫자 하나가 원인 추적의 출발점이었습니다.

15분의 정체: tcp_retries2

TCP 는 보낸 데이터에 대한 확인 응답(ACK)이 오지 않으면 같은 데이터를 다시 보냅니다. 재전송 간격은 200ms 에서 시작해 실패할 때마다 두 배로 늘고 120초에서 멈춥니다. 리눅스는 net.ipv4.tcp_retries2(기본 15)로 정해진 횟수만큼 시도한 뒤 연결을 포기하고, 소켓에 ETIMEDOUT 을 돌려줍니다. 포기까지 걸리는 시간은 계산할 수 있습니다.

앞의 10번 : 0.2 × (2^10 − 1) = 204.6초          (200ms 에서 두 배씩 늘어남)
그 뒤     : 다섯 번의 재전송과 마지막 대기가 각각 120초 → 120 × 6 = 720초
합계      : 924.6초 ≈ 15.4분

같은 식으로 다른 값도 계산해 보면 다음과 같습니다. 초기 재전송 간격을 200ms 로 잡은 이론값이고, 실제로는 왕복 시간에 따라 조금 더 걸립니다.

tcp_retries2 포기까지 걸리는 시간
5 약 12.6초
8 약 102.2초
10 약 324.6초
15 (기본값) 약 924.6초

실패 시각에서 924.6초를 빼면 요청이 시작된 시각이 나옵니다. 그렇게 계산해 보니 요청이 시작된 시각은 모두 어떤 사건이 있고 몇 분에서 수십 분이 지난 뒤였습니다. 사건 직후가 아니라 한참 뒤에 집힌 연결이라는 뜻입니다. 풀 안에서 조용히 놓여 있던 연결이 누군가에게 건네졌을 때 비로소 문제가 드러난 것입니다.

죽은 연결이란 무엇인가

TCP 는 연결을 열어 두고 아무 데이터도 오가지 않아도 스스로 상대가 살아 있는지 확인하지 않습니다. 상대가 FIN 이나 RST 를 보내 알려 주지 않으면 우리 쪽 소켓은 계속 ESTABLISHED 로 남습니다. 그리고 커넥션 풀은 "지금 놀고 있는 연결은 살아 있다"고 가정합니다.

이 가정이 깨지는 경우가 있습니다. 서버가 연결을 닫았는데 그 신호가 클라이언트에 닿지 못한 경우입니다. 그러면 풀 안에는 살아 있는 척하는 연결이 남습니다.

서버         : 연결을 닫음 (FIN/RST 를 보냈지만 클라이언트에 닿지 못함)
클라이언트   : 소켓은 여전히 ESTABLISHED, 풀은 "놀고 있는 정상 연결"로 보관
요청 도착    : 풀이 그 연결을 건넴 → 쿼리 전송 → ACK 없음
이후 15.4분  : 재전송 반복 → ETIMEDOUT → 요청 실패, 연결은 그제야 풀에서 제거됨

죽은 연결을 찾는 법

먼저 클라이언트 쪽만으로 보는 방법입니다. 컨테이너의 네트워크 네임스페이스 안에서 ss 로 DB 포트(3306) 연결의 타이머를 봅니다.

nsenter -t "$(docker inspect -f '{{.State.Pid}}' <컨테이너>)" -n \
  ss -tano '( dport = :3306 )'

마지막 열의 타이머가 단서입니다.

타이머 뜻
keepalive,남은시간,0 놀고 있는 연결. 정상일 수도, 아직 확인 전인 죽은 연결일 수도 있음
keepalive,남은시간,N (N>0) 커널이 보낸 확인 프로브가 N번 무응답 → 죽은 연결
on,남은시간,N 응답 없는 데이터를 N번째 재전송 중

다만 keepalive 는 기본값이면 2시간을 논 뒤에야 프로브를 보내기 시작하므로 이 방법만으로는 늦습니다. 서버 쪽 접속 목록과 대조하면 바로 알 수 있습니다.

SELECT SUBSTRING_INDEX(HOST, ':', -1) AS port, COMMAND, TIME
FROM information_schema.processlist;

NAT 가 출발 포트를 보존한다면, 컨테이너 소켓의 출발 포트에서 서버 목록의 포트를 뺀 나머지가 죽은 연결입니다. 저는 이 뺄셈으로 컨테이너 몇 개에서 죽은 연결 일곱 개를 찾았습니다.

왜 서버 쪽만 닫혔을까: 호스트의 자동 업그레이드

죽은 연결이 생긴 시점은 서버 쪽 지표에서 찾았습니다. 데이터베이스의 접속 수를 1분 단위로 보니, 어느 시각부터 5분에 걸쳐 계단처럼 줄어 있었습니다. 같은 시각의 호스트 로그를 열어 보니 범인은 무인 자동 업그레이드였습니다. 흐름은 이렇습니다.

  1. unattended-upgrades 가 보안 패치로 공유 라이브러리(이번에는 libssl)를 올렸습니다.
  2. 설치 직후 needrestart 가 그 라이브러리를 쓰는 서비스를 자동으로 재시작했습니다. 재시작 목록에 systemd-networkd 가 있었고, 업그레이드 로그에 그 목록이 그대로 남아 있었습니다.
  3. 네트워크 데몬이 재시작되면서 호스트 네트워크 인터페이스의 DHCP 임대를 다시 받았습니다.

여기서부터는 제가 알고 있는 커널 동작에 기대는 가설이고, 이 환경에서 재현하지는 못했습니다. 컨테이너에서 바깥으로 나가는 연결은 호스트에서 MASQUERADE 로 출발 주소가 바뀝니다. 커널의 masquerade 는 그 주소를 가진 인터페이스나 주소에 변화가 생기면 해당 연결의 추적(conntrack) 항목을 지웁니다. 추적 항목이 사라지면 서버가 보내는 패킷은 컨테이너로 전달되지 못하고 호스트 자신에게 온 것으로 처리됩니다. 호스트는 그 포트에 소켓이 없으니 RST 로 답하고, 서버는 그 RST 를 받아 연결을 닫습니다. 컨테이너는 FIN 도 RST 도 받지 못하니 그대로 ESTABLISHED 입니다.

서버 쪽 접속 수가 5분에 걸쳐 줄어든 모양은 연결마다 서버가 이 상태를 알아채는 시점이 달랐기 때문으로 설명됩니다. 서버 쪽 keepalive 프로브가 유력하지만 확인하지는 못했습니다. 그래도 호스트의 conntrack 표를 직접 읽어 본 결과는 가설과 방향이 맞았습니다. conntrack 도구가 없어 NETLINK_NETFILTER 소켓으로 표를 덤프했는데, 놀고 있던 죽은 연결에는 추적 항목이 아예 없었고 재전송 중이던 연결에는 응답을 한 번도 본 적 없는 항목만 있었습니다. 다만 항목이 다시 생긴 뒤에도 왜 응답이 오지 않는지는 설명하지 못했습니다.

우연이 아닌지 확인하기: 자연 실험

한 번의 사건만으로는 우연을 배제할 수 없습니다. 호스트에는 몇 주치 자동 업그레이드 기록이 남아 있었고, 데이터베이스 접속 수도 모니터링에 있었습니다. 그래서 업그레이드 실행 14회를 "네트워크 데몬이 재시작됐는가"로 나눠, 실행 전후 5분 단위 접속 수 변화를 대조했습니다.

구분 올라간 라이브러리 접속 수 변화
네트워크 데몬 재시작 있음 (4회) coreutils, util-linux 계열 3 → 0
libgcrypt, ncurses 1 → 0
libc6 13 → 3
libssl 16 → 3
재시작 없음 (10회) polkit, perl, sudo, curl 등 급감 없음 (최대 낙폭 0~3)

재시작이 있던 네 번은 모두 접속이 급감했고 없던 열 번은 그렇지 않았습니다. 우연으로 보기는 어렵다는 생각이 들었습니다. 다만 두 가지는 솔직하게 적어 두어야 합니다. 앞의 두 건은 접속이 3개와 1개뿐이라 무게가 작습니다. 그리고 네트워크 데몬과 함께 재시작되는 이름 해석기, 로그 수집기, 원격 접속 서버 같은 다른 서비스를 데이터로 분리하지 못했습니다. 증거는 "네트워크 데몬이 재시작된 날은 접속이 급감한 날"까지입니다.

풀은 왜 이 연결을 걷어내지 못했을까

원인의 절반은 호스트에 있었지만, 나머지 절반은 풀에 있었습니다. 죽은 연결이 생겨도 풀이 몇 분 안에 걷어냈다면 이런 사고는 나지 않았을 것입니다. 그래서 mysql2 3.23 의 소스를 열어 기본값이 실제로 무엇을 하는지 읽었습니다.

설정 기본값 실제 효과
maxIdle connectionLimit 과 같음 유휴 정리기가 시작조차 하지 않음
idleTimeout 60초 위 이유로 죽은 설정
keepAliveInitialDelay 미지정 OS 기본(7,200초 뒤에야 프로브 시작)
연결을 꺼낼 때 생존 확인 없음 죽은 연결도 그대로 건넴
자유 연결을 꺼내는 순서 LIFO 가장 최근에 쓴 연결부터 나감

핵심은 첫 줄입니다. 유휴 정리기는 maxIdle 이 connectionLimit 보다 작을 때만 시작합니다. 기본값이 둘을 같게 두므로 정리기는 한 번도 돌지 않고, 문서에서 본 idleTimeout 60초는 작동한 적이 없는 설정이었습니다. 솔직히 말하면 소스를 열어 보기 전까지는 저도 60초마다 정리되고 있다고 믿었습니다.

// mysql2 3.23 lib/base/pool.js 요약
풀 생성 시 : maxIdle < connectionLimit 이면 정리기(1초 주기)를 시작한다
정리기     : 자유 연결 수 > maxIdle 이거나, 가장 오래 논 연결이 idleTimeout 을 넘겼으면 그 연결을 destroy

LIFO 도 한몫했습니다. 자유 연결을 스택처럼 꺼내 쓰기 때문에 가장 최근에 쓴 연결이 먼저 나갑니다. 한동안 조용하던 풀에서 죽은 연결은 바닥에서 기다리다, 동시 요청이 몰려 여유가 바닥날 때 뒤늦게 집힙니다. 사건 직후가 아니라 한참 뒤에 실패가 나타난 이유가 이것입니다.

쿼리 제한 시간(timeout)을 걸면 되지 않을까 생각하기 쉽지만, 이것도 답이 아니었습니다. mysql2 의 쿼리 timeout 은 호출자에게 오류만 내고 소켓은 닫지 않습니다. pool.query() 는 명령이 끝났다는 end 신호에서만 연결을 반납하는데 제한 시간 경로는 그 신호를 내지 않습니다. 그래서 연결이 반납되지 못한 채 슬롯 하나가 소켓이 죽을 때까지 묶입니다. 죽은 연결이 여러 개면 풀 전체가 묶여 뒤 요청이 무제한 대기열에 쌓일 수 있습니다.

방어는 세 겹입니다

하나의 장치로 모든 경우를 막을 수는 없었습니다. 그래서 각각 다른 경로를 막는 세 겹을 쌓았습니다.

첫째 겹: 풀이 스스로 걷어내게 한다

정리기를 켜고 keepalive 에 시작 지연을 줍니다.

import mysql from 'mysql2/promise';

const CONNECTION_LIMIT = 10;

const pool = mysql.createPool({
  connectionLimit: CONNECTION_LIMIT,
  maxIdle: CONNECTION_LIMIT - 1, // 정리기를 켠다 (상한과 같으면 시작하지 않는다)
  idleTimeout: 30_000,           // 30초 넘게 논 연결은 로컬에서 destroy
  enableKeepAlive: true,
  keepAliveInitialDelay: 30_000, // 1000ms 미만이면 Node 가 0 으로 절삭해 OS 기본으로 돌아간다
});

keepalive 는 제가 처음에 잘못 계산한 부분입니다. OS 기본 간격(75초 × 9회)이 그대로 적용된다고 보고 "지연을 줄여도 죽은 것을 알아채는 데 12분이 걸린다"고 적었습니다. 그런데 격리 컨테이너에서 직접 재 보니 틀렸습니다. Node 에서 지연을 주면 libuv 가 소켓마다 확인 간격을 1초, 횟수를 10회로 덮어씁니다. 그래서 죽은 상대를 "지연 + 약 10초"에 알아챕니다. 지연이 5초일 때 15.6초 뒤에 ETIMEDOUT 이 났습니다. 재현 스크립트는 아래와 같습니다.

// keepalive-probe.js — 같은 프로세스의 루프백 연결을 iptables 로 양방향 DROP
const net = require('net');
const { execSync } = require('child_process');

const server = net.createServer((s) => { s.on('data', () => {}); s.on('error', () => {}); });
server.listen(0, '127.0.0.1', () => {
  const port = server.address().port;
  const c = net.connect(port, '127.0.0.1');
  let t0;
  c.on('error', (e) => { console.log(e.code, ((Date.now() - t0) / 1000).toFixed(1), '초'); process.exit(0); });
  c.on('connect', () => {
    c.setKeepAlive(true, 5000);
    execSync(`iptables -A INPUT -p tcp --dport ${port} -j DROP && iptables -A INPUT -p tcp --sport ${port} -j DROP`);
    t0 = Date.now();
  });
});
docker run --rm --cap-add NET_ADMIN -v "$PWD/keepalive-probe.js:/p.js:ro" node:24-slim \
  sh -c 'apt-get update -qq && apt-get install -y -qq iptables && node /p.js'
# 출력: ETIMEDOUT 15.6 초

트레이드오프도 있습니다. destroy() 는 COM_QUIT 없이 소켓을 닫으므로 서버의 Aborted_clients 카운터가 오릅니다. 다행히 기본 오류 로그 수준에서는 로그에 남지 않았습니다. 조용한 시간에는 풀이 0개까지 줄고, 다음 요청이 재연결 비용을 치릅니다. 이 장치는 또한 요청이 이미 걸린 연결은 구하지 못합니다. 응답을 못 받은 데이터가 이미 나가 있는 소켓에는 keepalive 프로브 대신 재전송 타이머가 일하기 때문입니다.

둘째 겹: 커널이 포기하는 시간을 줄인다

요청이 이미 걸린 연결의 상한은 tcp_retries2 입니다. 이 값은 네트워크 네임스페이스 단위라서 컨테이너마다 따로 정할 수 있습니다. 호스트와 다른 컨테이너는 영향을 받지 않습니다.

docker run --rm --sysctl net.ipv4.tcp_retries2=8 node:24-slim cat /proc/sys/net/ipv4/tcp_retries2
# 8  (같은 이미지를 옵션 없이 실행하면 15)
services:
  api:
    sysctls:
      net.ipv4.tcp_retries2: 8  # 응답 없는 연결을 약 102초 뒤 포기 (기본 15는 약 15분)

compose 파일이 아니라 PaaS 의 앱 설정으로 배포한다면, 같은 값을 그 앱의 "사용자 지정 도커 실행 옵션" 칸에 --sysctl net.ipv4.tcp_retries2=8 로 넣습니다. 이 방식으로 배포한 컨테이너 안에서 /proc/sys/net/ipv4/tcp_retries2 가 8 로 읽히는 것을 확인했습니다. 같은 호스트의 옵션 없는 컨테이너와 호스트 자체는 15 그대로였습니다. 옵션은 컨테이너를 새로 만들 때 적용되므로 값을 바꾼 뒤에는 재배포가 필요합니다. 재배포가 그 시점의 최신 코드를 빌드하는 플랫폼이라면 옵션만 바꿀 생각이어도 다른 사람이 방금 병합한 변경이 함께 올라가므로, 그 변경이 필요로 하는 데이터베이스 마이그레이션이 이미 적용됐는지 먼저 확인해야 합니다.

8 로 낮춘 컨테이너에서 응답 없는 상대에 데이터를 쓰니 106.1초 만에 실패했습니다. 이론값 102.2초에 왕복 시간이 더해진 값입니다. 기본값이라면 924.6초를 기다렸을 요청입니다.

이 설정은 그 컨테이너가 바깥으로 여는 모든 TCP 연결에 적용됩니다. DB 만이 아니라 외부 API 호출도 포함됩니다. 서버가 느리게 답하는 경우는 영향이 없습니다. 재전송은 패킷이 아예 닿지 않을 때만 일어나기 때문입니다. 대신 100초를 넘는 네트워크 단절이 생기면 그 연결은 살리지 못하고 실패로 끝납니다. 예전에는 15분을 기다린 뒤 같은 실패로 끝났으므로 빨리 실패하는 쪽이 낫다고 판단했습니다.

셋째 겹: 트리거 자체를 없앤다

앞의 두 겹은 죽은 연결이 생긴 뒤의 방어입니다. 생기는 것 자체를 막으려면 서비스를 자동으로 재시작하지 않으면 됩니다. needrestart 는 설정으로 "목록만 보이고 재시작하지 않는" 모드를 지원합니다.

# /etc/needrestart/conf.d/50-list-only.conf
$nrconf{restart} = 'l';  # 라이브러리 갱신 뒤에도 서비스를 자동 재시작하지 않고 목록만 보인다
# /etc/systemd/system/apt-daily-upgrade.timer.d/override.conf  (시각은 UTC)
[Timer]
OnCalendar=
OnCalendar=*-*-* 18:00
RandomizedDelaySec=30m

apt 후처리 훅은 needrestart 를 재시작 모드 옵션 없이 부르기 때문에 설정 파일의 값이 그대로 쓰입니다. needrestart -vv 로 설정 파일이 실제로 읽히는지 확인했습니다. 한 가지 순서에 주의해야 합니다. 이 설정을 먼저 바꾼 뒤에 패키지를 설치해야 합니다. 설치 자체가 apt 후처리를 타서 자동 재시작을 부를 수 있기 때문입니다.

대가도 분명합니다. 자동 재시작이 꺼지면 라이브러리 갱신 뒤에도 서비스는 사람이 재시작할 때까지 옛 코드로 돕니다. 그래서 대기 중인 보안 패치와 재시작이 필요한 서비스 목록을 주기적으로 메일로 받게 했습니다. 또 한 가지, live-restore 가 꺼진 Docker 데몬은 재시작하면 모든 컨테이너가 함께 재시작되므로 재시작 시간을 사람이 정해야 합니다. 사람이 하는 재시작이라도 네트워크 데몬을 재시작하면 같은 죽은 연결이 생긴다는 점은 기억해야 합니다.

세 겹이 남기는 것

겹 줄이는 것 남는 것
풀 (idleTimeout · keepalive) 놀고 있는 죽은 연결이 풀에 머무는 시간 (약 30~40초) 죽은 뒤 그 안에 집힌 연결, 이미 쓰던 연결
커널 (tcp_retries2) 응답 없는 요청이 멈추는 시간 (15분 → 약 100초) 100초 동안은 여전히 멈춤
호스트 (needrestart) 죽은 연결이 생기는 특정 사건 다른 원인 (DB 장애 조치, 네트워크 단절)

검증: 옵션을 지웠을 때 실패하는 테스트

이런 옵션은 지워도 로컬과 CI 가 초록입니다. 테스트 환경에서는 네트워크가 끊기지 않기 때문입니다. 그래서 "옵션이 걸려 있는가"를 DB 없이 고정하는 테스트가 필요했습니다. 세 가지를 지켰습니다.

  • 설정값 단언에는 실제 불변식을 씁니다. 처음에 keepAliveInitialDelay > 0 으로 썼다가 리뷰에서 지적을 받았습니다. 999ms 는 Node 가 0 으로 절삭해 OS 기본으로 돌아가는데 이 단언은 통과했기 때문입니다. 하한을 1초로 고쳤습니다.
  • 진짜 mysql2 풀을 만들어 정리기가 시작했는지 확인합니다. 비공개 필드를 읽는 대신 가짜 타이머 개수를 셉니다.
  • 옵션을 하나씩 망가뜨려 테스트가 실제로 실패하는지 확인합니다. 열 가지 변이(상한과 같은 maxIdle, 지연 삭제, 오타 등)가 모두 빨갛게 되어야 합니다.
vi.useFakeTimers(); // 정리기의 1초 타이머를 세려면 풀을 만들기 전에 켠다
const pool = realMysql.createPool({ ...cfg, host: '127.0.0.1', port: 1 }); // 연결하지 않는다
expect(vi.getTimerCount()).toBe(1);  // maxIdle < connectionLimit 일 때만 정리기 타이머가 생긴다
await pool.end();
expect(vi.getTimerCount()).toBe(0);  // 종료하면 타이머도 걷힌다

측정에서 배운 것

애석하게도 원인 분석 중에 저도 한 번 속았습니다. "이런 급감은 최근 일주일에 이번 한 번뿐"이라는 결과를 얻었는데, 알고 보니 조회가 실패한 것이었습니다. CloudWatch GetMetricStatistics 는 한 번에 1,440개 데이터포인트까지만 돌려줍니다. 5분 단위로 7일치(2,016개)를 요청하면 오류가 나는데, 제 스크립트가 그 오류를 빈 결과로 삼켜 "급감 없음"처럼 보였습니다. 그 뒤로는 두 가지를 습관으로 삼았습니다.

  • 긴 기간은 창을 나눠 조회합니다. 5분 단위라면 이틀씩 자릅니다.
  • "없다"고 결론 내리기 전에 알려진 사건이 잡히는지 양성 대조를 넣습니다. 이번 사건이 결과에 없다면 그 검사는 아무것도 증명하지 못합니다.

정리

돌이켜 생각해보면 이번 사고에서 배운 것은 결국 하나로 모입니다. "열려 있다"와 "살아 있다"는 다른 말이라는 것입니다. 풀은 놀고 있는 연결을 살아 있다고 믿지만, 그 믿음을 확인하는 장치는 계층마다 따로 있고 기본값으로는 꺼져 있거나 매우 느립니다.

계층 장치 지키는 대상
풀 maxIdle, idleTimeout 오래 놀고 있는 연결
소켓 keepalive (시작 지연 지정) 유휴 상태에서 죽은 연결
커널 tcp_retries2 이미 쓰고 있는 연결
쿼리 timeout 느린 쿼리 (죽은 연결은 못 구함)
호스트 needrestart 죽은 연결이 생기는 사건

중요한 것은 이 장치들이 서로를 대신하지 못한다는 점입니다. 한 겹씩 원인을 좁혀 가는 과정에서, 가설은 세우되 측정으로 확인하지 못한 부분은 끝까지 가설이라고 적어 두는 것이 안전하다는 생각이 들었습니다. 자동 업그레이드 같은 평범한 운영 작업이 애플리케이션의 15분 정지로 이어질 수 있다는 사실도, 다음에 비슷한 증상을 만나면 가장 먼저 시각을 살펴보게 할 것 같습니다.

MySQL커넥션 풀TCPNode.jsLinux트러블슈팅