Huny's Dev Blog
huny.dev

Cloudflare의 무중단 업그레이드 라이브러리 Ecdysis 해부: 소켓 상속과 연결 드레인으로 ECONNREFUSED 막기

네트워크 서비스 업그레이드 시 발생하는 ECONNREFUSED와 SO_REUSEPORT의 연결 유실 문제를 해결하는 Cloudflare의 Ecdysis 아키텍처를 분석한다. Fork-then-exec 모델, Listening 소켓 FD 상속, 준비 완료 핸드셰이크, 무중단 드레인 메커니즘과 로컬 Python 검증 시뮬레이터를 정리한다.

Hun Jang
Hun Jang Nov 7, 2026

네트워크 서비스의 소켓 파일 디스크립터 상속과 무중단 프로세스 핸드오버를 상징하는 추상 비주얼 아트워크다.

네트워크 서비스를 24시간 무중단으로 운영하는 엔지니어에게 바이너리 배포와 프로세스 재시작은 가장 신경 쓰이는 작업 중 하나다. 단순한 패치나 설정 변경을 배포할 때조차 수십 밀리초(ms) 단위의 포트 바인딩 공백이 발생하면 대규모 트래픽 환경에서는 수만 건의 연결이 거부(ECONNREFUSED)되거나 기존 진행 중이던 스트림이 강제로 끊어지는 장애로 이어진다. 결론부터 말하면, Cloudflare는 글로벌 엣지 네트워크 서비스의 바이너리 무중단 업그레이드를 위해 5년간 내부에서 검증하고 다듬은 Rust 오픈소스 라이브러리 'Ecdysis'를 공개했으며, SO_REUSEPORT의 씬 큐 고립 한계를 극복하기 위해 Fork-then-Exec 기반의 Listening 소켓 파일 디스크립터(FD) 상속과 준비 완료(Readiness) 핸드셰이크, 그리고 점진적 연결 드레인(Connection Draining)을 결합하여 다운타임(ECONNREFUSED) 없는 완벽한 프로세스 핸드오버를 구현했다. Cloudflare 엔지니어링 블로그

많은 개발자가 프로세스 재시작 시 리눅스 커널의 SO_REUSEPORT 소켓 옵션을 활용하면 무중단 배포를 쉽게 달성할 수 있을 것으로 기대하지만, 이는 심각한 연결 유실 함정을 내포하고 있다. 커널이 구 프로세스의 백로그 큐에 분배한 SYN 패킷들은 구 프로세스가 종료될 때 커널에 의해 버려지며 클라이언트에게 ECONNRESET으로 돌아오기 때문이다. 따라서 진정한 무중단 교체는 포트를 새로 바인딩하는 것이 아니라, 이미 열려 있는 단일 Listening 소켓 자체를 다음 세대 프로세스로 안전하게 인계(Handover)해야 한다.

이 글은 네트워크 서비스 재시작 시 발생하는 근본적 문제, SO_REUSEPORT의 구조적 결함, Cloudflare Ecdysis의 소켓 FD 상속 및 4단계 무중단 핸드오버 아키텍처, Cloudflare 생태계의 재시작 라이브러리군(Ecdysis, Shellflip, Udpgrm) 비교, 그리고 로컬 Python으로 직접 검증한 6개 시나리오와 실무 도입 체크리스트를 정리한다. 커널 eBPF 패킷 리다이렉션 기법이나 특정 클라우드 L4/L7 로드밸런서의 BGP 애니캐스트 세부 라우팅 제어는 이번 글의 범위에 포함하지 않는다.

1. 네트워크 서비스 재시작의 딜레마와 기존 방식의 치명적 한계

프로세스 업그레이드 시 엔지니어가 직면하는 문제는 크게 두 가지다. 첫째, 기존 프로세스가 죽고 새 프로세스가 포트를 열 때까지 발생하는 **포트 바인딩 공백(Port Gap)**이다. 둘째, 기존 프로세스에서 처리 중이던 연결이 비정상 종료되는 **연결 중단(Connection Drop)**이다.

구분 항목
단순 재시작 (Stop → Start)
SO_REUSEPORT 다중 바인딩
Cloudflare Ecdysis (FD 상속)
신규 연결 거부 (ECONNREFUSED)
수십~수백ms 동안 대량 발생
거의 없음
0건 (Listening 소켓 상시 유지)
커널 SYN 백로그 큐 유실
전량 유실 (프로세스 즉시 소멸)
구 프로세스 큐 고립 후 유실
유실 없음 (단일 큐를 자식이 즉시 인계)
기존 활성 연결 (In-flight)
즉시 강제 종료 (ECONNRESET)
드레인 구현 시 보존 가능
Graceful Draining 완료 후 프로세스 종료
포트 충돌 위험 (EADDRINUSE)
발생 가능 (TIME_WAIT 상태 등)
없음 (커널이 멀티바인드 허용)
없음 (신규 바인딩 없이 기존 FD 재사용)
자식 초기화 실패 시 롤백
서비스 전체 중단
구 프로세스 유지 시 부분 방어
부모 프로세스가 안전하게 서비스 유지
상태 및 메모리 격리
완벽 격리 (새 프로세스)
완벽 격리 (새 프로세스)
Fork-Exec로 깨끗한 주소 공간 보장

1) 단순 재시작(Naive Stop-Start)의 포트 공백

기존 프로세스에 SIGTERM을 보내 종료한 뒤 새 버전의 바이너리를 구동하는 전통적 방식은 아무리 빨라도 프로세스 기동, 설정 파싱, TLS 인증서 로딩, 스레드 풀 초기화에 수십에서 수백 밀리초가 소요된다. 이 시간 동안 OS 커널은 해당 포트로 들어오는 모든 TCP SYN 패킷에 대해 즉각 RST를 반환하며, 클라이언트는 ECONNREFUSED 오류를 마주하게 된다. 초당 수만 쿼리가 쏟아지는 분산 환경에서는 이 짧은 공백만으로도 수천 건의 트랜잭션 에러가 폭증한다.

2) SO_REUSEPORT의 함정: 씬 큐 고립(Orphaned in SYN Queue)

리눅스 3.9에 도입된 SO_REUSEPORT는 여러 프로세스가 동일한 포트에 bind()할 수 있도록 지원하여 겉보기에는 무중단 배포의 은총알처럼 보인다. 새 버전의 프로세스를 먼저 띄워 포트를 바인딩한 뒤 구 버전을 내리면 다운타임이 없을 것이라 착각하기 쉽다.

그러나 리눅스 커널은 들어오는 SYN 패킷을 해시 알고리즘에 따라 각 소켓의 백로그 큐(Listen Backlog Queue)로 분배한다. 새 프로세스가 시작되어도 커널은 유입 트래픽을 구 프로세스와 신 프로세스 양쪽에 나누어 할당한다. 이때 구 프로세스가 종료 시퀀스에 돌입해 close()를 호출하면, 구 프로세스의 소켓 큐에 이미 진입해 대기 중이던 삼자 핸드셰이크 완료 연결들이 그대로 버려진다. 클라이언트는 SYN-ACK를 받고 ACK를 보냈으나 서버 프로세스가 소멸하여 연결이 즉시 파기되는 기이한 ECONNRESET 현상을 겪게 된다.

2. Cloudflare Ecdysis의 핵심 아키텍처와 소켓 상속 메커니즘

Cloudflare의 Ecdysis는 NGINX가 증명해 온 고전적이면서도 가장 견고한 Fork-then-Exec 기반 소켓 상속 모델을 현대 Rust 비동기 런타임에 맞게 라이브러리화한 결과물이다.

기존 재시작 방식의 연결 거부·고립 문제(상단)와 Cloudflare Ecdysis의 Fork-Exec 소켓 상속 및 무중단 드레인 4단계 아키텍처(하단)를 비교한 도식이다.

Ecdysis의 무중단 업그레이드 사이클은 4단계의 엄밀한 핸드셰이크 프로토콜로 작동한다:

1) Step 1: Fork-then-Exec와 FD_CLOEXEC 관리

업그레이드 신호(예: SIGHUP 또는 관리자 CLI 명령)가 전달되면 부모 프로세스는 fork()를 실행하여 자식 프로세스를 생성하고, 자식은 execve()를 호출하여 디스크의 신규 바이너리 이미지로 자신을 완전히 교체한다.

  • 깨끗한 메모리 공간: execve()는 가상 메모리 공간을 완전히 새로 할당하므로 이전 버전의 메모리 파편화나 런타임 누수가 자식에게 전파되지 않는다.
  • 파일 디스크립터 선별 상속: 리눅스에서 기본적으로 열린 파일 디스크립터(FD)에는 FD_CLOEXEC 플래그가 설정되어 execve 시 닫힌다. Ecdysis는 Listening Socket에 대해서만 명시적으로 FD_CLOEXEC를 해제하여 자식 프로세스가 동일한 FD 번호(또는 환경변수로 전달된 번호)를 그대로 넘겨받을 수 있도록 보장하고, 로그 파일이나 내부 DB 소켓 등 불필요한 FD는 철저히 닫는다.

2) Step 2: 자식 프로세스 초기화 중 부모의 지속 수신

신규 자식 프로세스가 기동되어 설정 파일 파싱, 캐시 워밍업, 암호화 키 로딩을 수행하는 동안에도 Listening 소켓은 닫히지 않고 열려 있다. 부모 프로세스는 여전히 이벤트 루프에서 해당 소켓의 accept()를 호출하며 모든 유입 트래픽을 처리한다. 따라서 자식 프로세스의 초기화가 아무리 오래 걸리더라도 클라이언트 측 다운타임은 0ms다.

3) Step 3: 준비 완료(Readiness Signal) 통보와 트래픽 바통 터치

자식 프로세스가 모든 기동 절차를 마치고 신규 연결을 처리할 준비가 완료되면, 부모와 사전에 연결해 둔 내부 통신 채널(Unix Domain Socket 또는 무명 파이프)을 통해 Readiness Signal을 전송한다.

  • 신호를 받은 부모 프로세스는 **즉시 Listening 소켓의 accept 루프를 탈출(중단)**한다.
  • 동시에 자식 프로세스가 상속받은 Listening 소켓에서 본격적으로 accept()를 시작한다.
  • 소켓 자체가 한 번도 닫히지 않았으므로 커널의 SYN 큐는 끊김 없이 연속성을 유지하며 신규 요청을 온전히 자식 프로세스로 전달한다.

4) Step 4: 활성 연결 드레인(Connection Draining)과 정상 종료

부모 프로세스는 신규 연결 수신을 중단한 후, 이미 수립되어 처리 중이던 활성 연결(In-flight Connections)을 점진적으로 정리하는 Draining 단계로 전환한다.

  • HTTP Keep-Alive 연결에는 Connection: close 응답 헤더를 내려보내 클라이언트가 후속 요청을 신규 프로세스로 보내도록 유도한다.
  • 대용량 파일 전송이나 장기 WebSocket 연결이 정상 종료될 때까지 대기하며, 기설정된 Graceful Shutdown 타임아웃을 적용해 무한정 지연을 방지한다.
  • 모든 활성 연결 수가 0에 도달하면 부모 프로세스는 리소스를 정리하고 exit 0으로 클린 종료된다. 이제 자식 프로세스가 독립적인 단일 주 서비스로 완전히 안착한다.

3. Cloudflare 재시작 라이브러리 삼총사: Ecdysis, Shellflip, Udpgrm

Cloudflare는 다루는 프로토콜과 프록시의 성격에 따라 서로 다른 세 가지 특화 무중단 라이브러리를 구축하여 운용하고 있다.

  • Ecdysis (범용 TCP 네트워크 서비스):
    표준적인 TCP 서비스(리버스 프록시, API 게이트웨이, 내부 마이크로서비스)의 바이너리 무중단 업그레이드를 위한 라이브러리다. 외부 오케스트레이터(systemd 등)에 종속되지 않고 프로세스 자체적으로 Fork-Exec 기반 소켓 핸드오버를 완결한다.
  • Shellflip (상태 보존형 L7 프록시 특화):
    Cloudflare의 차세대 프록시 플랫폼인 Oxy와 같이 복잡한 인메모리 상태(TLS 세션 캐시, 업스트림 헬스체크 결과, 장기 연결 컨텍스트)를 상속해야 하는 서비스에 사용된다. systemd 소켓 활성화 및 Tokio 비동기 런타임과 긴밀히 결합되어 부모의 애플리케이션 상태를 직렬화하여 자식에게 전송한다.
  • Udpgrm (UDP / HTTP/3 / QUIC / WireGuard 특화):
    UDP는 비연결형 프로토콜이므로 커널 레벨에서 단일 Listening 소켓 개념이 TCP와 전혀 다르게 동작한다. 커널 소켓 옵션과 eBPF 소켓 디스패치를 결합하여 QUIC 및 WireGuard 세션이 끊기지 않고 새 프로세스로 자연스럽게 인계되도록 설계된 전문 라이브러리다.

4. 로컬 Python으로 구현·검증하는 무중단 프로세스 업그레이드 시뮬레이터

외부 패키지 없이 Python 표준 라이브러리만으로 단순 재시작의 ECONNREFUSED 결함, SO_REUSEPORT의 커널 백로그 큐 고립 현상, 그리고 Ecdysis의 Listening FD 상속·Readiness 신호·무중단 드레인 메커니즘을 시뮬레이션하고 검증하는 스크립트(ecdysis_graceful_restart_sim.py)다.

직접 실행해 확인한 범위

로컬 환경에서 python ecdysis_graceful_restart_sim.py를 실행해 다음 6개 검사가 모두 통과(6 checks passed)함을 확인했다.

  1. 단순 재시작의 다운타임 증명: 기존 프로세스를 종료한 뒤 새 버전을 띄우는 사이의 포트 공백 구간에서 유입된 5개 연결이 예외 없이 ECONNREFUSED 에러로 드롭됨을 검증한다.
  1. SO_REUSEPORT 백로그 큐 고립 현상 재현: 커널이 구 프로세스 소켓 백로그에 사전 분배한 3개 연결이 구 프로세스 종료와 함께 ECONNRESET으로 유실되는 구조적 취약점을 확인한다.
  1. Listening 소켓 FD 상속 및 유실 0건 보장: 부모 프로세스가 바인딩한 파일 디스크립터(FD: 42)가 자식 프로세스로 온전히 상속되며, 업그레이드 전 과정에서 단 1건의 연결 유실도 발생하지 않음(dropped_count=0)을 입증한다.
  1. 자식 기동 구간의 부모 연속 서빙 검증: 자식 프로세스가 초기화되는 동안(Phase 1) 유입된 신규 연결 2건을 부모 프로세스가 끊김 없이 처리하여 다운타임 0초를 유지함을 확인한다.
  1. Readiness 수신 후 부모의 무손실 Draining 완결: 자식이 준비 완료 신호를 보낸 즉시 부모는 신규 accept를 중단하고, 진행 중이던 기존 연결 5건(사전 연결 3건 + 초기화 중 수신 2건)을 정상 완료 후 클린 종료(closed_cleanly=True)함을 확인한다.
  1. 신규 버전의 단일 활성 인스턴스 승격: 부모 종료 후 자식 프로세스(v2.0.0)가 상속받은 FD에서 신규 연결 3건을 단독 수신하며 활성 상태(ACTIVE)로 안착함을 확인한다.

5. 실무 무중단 업그레이드 도입 체크리스트

  • Listening 소켓의 FD_CLOEXEC 제어 확인: 런타임에서 Listening Socket FD를 생성할 때 무조건 CLOEXEC가 켜지는 프레임워크나 라이브러리를 점검하고, fork-exec 직전 전달 대상 FD의 플래그를 정확히 제어하는지 검증한다.
  • Seccomp 및 컨테이너 샌드박스 정책 허용: Ecdysis는 fork()와 execve() 시스템 콜을 직접 호출하므로, 엄격한 Seccomp 프로필이나 컨테이너 보안 정책에서 해당 시스템 콜이 차단되지 않는지 사전에 점검한다.
  • Draining 데드라인(Graceful Timeout) 강제: 장기 WebSocket 연결이나 느린 클라이언트(Slowloris)로 인해 구 프로세스가 영원히 종료되지 못하는 현상을 막기 위해, 일정 시간(예: 30초~60초) 경과 후 잔여 연결에 FIN/RST를 보내고 강제 종료하는 타임아웃 타이머를 반드시 둔다.
  • Readiness Signal 검증 타임아웃 및 롤백: 자식 프로세스가 패닉이나 설정 오류로 인해 지정된 시간 내에 Readiness Signal을 부모에게 보내지 못할 경우, 부모 프로세스가 자식을 강제 회수(SIGKILL)하고 기존 서비스를 중단 없이 지속하는 장애 복구 로직을 구현한다.
  • 모니터링 메트릭의 인스턴스 분리: 부모 프로세스와 자식 프로세스가 일시적으로 공존할 때 Prometheus 메트릭이나 통계 카운터가 충돌하지 않도록 PID 또는 Generation ID를 라벨에 포함해 분리 수집한다.
  • L4/L7 헬스체크 엔드포인트 연동: 상위 로드밸런서(Envoy, NGINX, 클라우드 ALB)가 프로세스 교체 과정에서 인스턴스를 Unhealthy로 오판하지 않도록 헬스체크 핸들러의 응답성을 보장한다.

참고 자료

확인 기준일: November 7, 2026

추천 글