Cloudflare가 Rust 서비스에서 연결을 끊지 않고 프로세스를 우아하게 재시작(그레이스풀 리스타트)하기 위해 ecdysis를 설계·운영해 온 방식과 보안/운영상의 고려사항, 그리고 실전 예제를 소개합니다.

ecdysis | ˈekdəsəs |
명사
(파충류의) 오래된 피부를 벗어내거나 (곤충 및 기타 절지동물의) 외피를 탈피하는 과정.
전 세계에서 초당 수백만 건의 요청을 처리하는 네트워크 서비스를, 단 하나의 연결도 끊지 않고 어떻게 업그레이드할 수 있을까요?
Cloudflare가 이 거대한 과제를 해결하기 위해 오래전부터 사용해 온 해법 중 하나가 바로 ecdysis입니다. ecdysis는 살아 있는 연결을 하나도 떨어뜨리지 않고, 새 연결도 거부하지 않는 우아한(그레이스풀) 프로세스 재시작을 구현한 Rust 라이브러리입니다.
지난달 ecdysis를 오픈 소스로 공개했기 때문에 이제 누구나 사용할 수 있습니다. Cloudflare에서 5년간 프로덕션에서 사용되며, ecdysis는 Cloudflare의 핵심 Rust 인프라 전반에서 무중단 업그레이드를 가능하게 해 왔고, Cloudflare의 글로벌 네트워크 전반에서 재시작마다 수백만 건의 요청을 구해내는 효과를 입증했습니다.
특히 Cloudflare 네트워크 규모에서는 업그레이드를 제대로 수행하는 것이 얼마나 중요한지 아무리 강조해도 지나치지 않습니다. 우리의 많은 서비스는 트래픽 라우팅, TLS 라이프사이클 관리, 방화벽 규칙 적용 같은 핵심 작업을 수행하며, 상시 가동되어야 합니다. 이런 서비스 중 하나라도 아주 잠깐이라도 다운되면 연쇄적인 영향이 치명적일 수 있습니다. 연결이 끊기고 요청이 실패하면 고객 성능 저하와 비즈니스 영향으로 빠르게 이어집니다.
이런 서비스가 업데이트가 필요할 때, 보안 패치는 기다려주지 않습니다. 버그 수정은 배포되어야 하고, 새 기능도 롤아웃되어야 합니다.
가장 단순한 접근은 기존 프로세스를 멈춘 뒤 새 프로세스를 띄우는 것입니다. 하지만 이 방식은 연결이 거부되고 요청이 떨어지는 시간 창(window)을 만듭니다. 한 지역에서 초당 수천 건을 처리하는 서비스가 있다고 할 때, 이를 수백 개 데이터 센터로 곱하면 짧은 재시작도 전 세계적으로 수백만 건의 실패 요청이 됩니다.
이제 문제를 자세히 살펴보고, ecdysis가 우리에게 어떤 해법이었는지(그리고 여러분에게도 그럴 수 있는지) 알아보겠습니다.
앞서 언급했듯이, 서비스를 재시작하는 가장 단순한 방법은 기존 프로세스를 중지하고 새 프로세스를 시작하는 것입니다. 실시간 요청을 처리하지 않는 단순 서비스라면 그럭저럭 동작하지만, 라이브 연결을 처리하는 네트워크 서비스에는 치명적인 한계가 있습니다.
첫째, 이 단순한 방법은 들어오는 연결을 받을 프로세스가 없는 시간 창을 만듭니다. 기존 프로세스가 멈추면 리스닝 소켓을 닫고, OS는 즉시 새 연결을 ECONNREFUSED로 거부합니다. 새 프로세스가 즉시 뜬다고 해도, 밀리초든 초든 “아무도 연결을 받지 않는” 공백이 생길 수밖에 없습니다. 초당 수천 건을 처리하는 서비스에서는 100ms 공백만으로도 수백 개 연결이 떨어질 수 있습니다.
둘째, 기존 프로세스를 중지하면 이미 성립된 모든 연결이 죽습니다. 큰 파일을 업로드하거나 동영상을 스트리밍 중인 클라이언트는 갑자기 끊깁니다. WebSocket이나 gRPC 스트림 같은 장기 연결은 작업 중간에 종료됩니다. 클라이언트 관점에서는 서비스가 그냥 사라진 것처럼 보입니다.
기존 프로세스를 종료하기 전에 새 프로세스가 먼저 바인딩하도록 하면 해결될 것 같지만, 또 다른 문제를 가져옵니다. 커널은 보통 하나의 프로세스만 주소:포트에 바인딩하도록 허용하지만, SO_REUSEPORT 소켓 옵션을 사용하면 여러 프로세스의 바인딩이 가능합니다. 하지만 이 방식은 프로세스 전환 과정에서의 문제 때문에 우아한 재시작에 적합하지 않습니다.
SO_REUSEPORT를 사용하면 커널은 프로세스마다 별도의 리스닝 소켓을 만들고, 새 연결을 이 소켓들 사이에 로드 밸런싱합니다. 연결의 초기 SYN 패킷이 들어오면 커널이 그 연결을 특정 리스닝 프로세스에 할당합니다. 핸드셰이크가 완료되면, 그 연결은 해당 프로세스의 accept() 큐에 머물다가 프로세스가 accept할 때까지 대기합니다. 그런데 그 프로세스가 연결을 accept하기 전에 종료하면, 연결은 고아(orphan)가 되어 커널에 의해 종료됩니다. GitHub 엔지니어링 팀은 GLB Director 로드 밸런서를 만들며 이 문제를 자세히 문서화했습니다.
ecdysis를 설계하고 구현하면서 우리는 라이브러리의 핵심 목표를 네 가지로 정의했습니다.
업그레이드 이후 오래된 코드는 완전히 종료될 수 있어야 한다.
새 프로세스는 초기화를 위한 유예 기간(grace period)을 가져야 한다.
초기화 중 새 코드가 크래시하더라도 괜찮아야 하며, 실행 중인 서비스에 영향을 주지 않아야 한다.
연쇄 실패를 막기 위해 동시에 병렬로 진행되는 업그레이드는 하나만 허용해야 한다.
ecdysis는 초창기부터 우아한 업그레이드를 지원해 온 NGINX가 개척한 접근을 따르며, 흐름은 간단합니다.
fork()로 새 자식 프로세스를 만든다.execve()로 새 버전 코드로 자신을 교체한다.
핵심은 전환 내내 소켓이 열린 상태로 유지된다는 점입니다. 자식 프로세스는 부모로부터 리스닝 소켓을 named pipe로 공유된 파일 디스크립터로 상속받습니다. 자식의 초기화 동안 두 프로세스는 커널의 동일한 기반 데이터 구조를 공유하므로, 부모는 계속해서 새 연결과 기존 연결을 받아 처리할 수 있습니다. 자식이 초기화를 마치면 부모에게 알리고 연결을 받아들이기 시작합니다. 부모는 준비 완료 알림을 받는 즉시 자신이 가진 리스닝 소켓의 복사본을 닫고, 기존 연결만 처리하면서 드레인합니다.
이 과정은 “아무도 듣고 있지 않은” 공백을 제거하는 동시에, 자식에게 안전한 초기화 창을 제공합니다. 짧은 시간 동안 부모와 자식이 동시에 연결을 accept할 수 있는데, 이는 의도된 동작입니다. 부모가 받아들인 연결은 드레인 과정의 일부로서 완료될 때까지 처리하면 됩니다.
이 모델은 필요한 크래시 안전성도 제공합니다. 자식 프로세스가 초기화 중(예: 설정 오류) 실패하면 그냥 종료됩니다. 부모는 리스닝을 멈춘 적이 없으므로 연결이 끊기지 않으며, 문제가 해결되면 업그레이드를 다시 시도할 수 있습니다.
ecdysis는 이 포킹 모델을 Tokio를 통한 비동기 프로그래밍 1급 지원과 systemd 통합과 함께 제공합니다.
Tokio 통합: Tokio를 위한 네이티브 async 스트림 래퍼를 제공합니다. 상속된 소켓은 별도 글루 코드 없이 리스너가 됩니다. 동기식 서비스의 경우에도 ecdysis는 비동기 런타임을 필수로 요구하지 않고 동작할 수 있습니다.
systemd-notify 지원: systemd_notify 기능을 활성화하면 ecdysis가 systemd의 프로세스 라이프사이클 알림과 자동으로 통합됩니다. 서비스 유닛 파일에서 Type=notify-reload를 설정하면 systemd가 업그레이드를 올바르게 추적할 수 있습니다.
systemd named socket 지원: systemd_sockets 기능은 systemd로 활성화(소켓 액티베이션)된 소켓을 ecdysis가 관리할 수 있게 합니다. 서비스는 소켓 액티베이션과 우아한 재시작을 동시에 지원할 수 있습니다.
플랫폼 참고: ecdysis는 소켓 상속과 프로세스 관리를 위해 Unix 전용 syscall에 의존합니다. Windows에서는 동작하지 않습니다. 이는 포킹 접근의 근본적인 한계입니다.
우아한 재시작은 보안 측면의 고려사항을 동반합니다. 포킹 모델은 짧은 시간 동안 두 세대의 프로세스가 공존하며, 둘 다 동일한 리스닝 소켓과 잠재적으로 민감한 파일 디스크립터에 접근할 수 있는 창을 만듭니다.
ecdysis는 설계를 통해 이러한 우려를 완화합니다.
fork 후 즉시 exec: ecdysis는 전통적인 Unix 패턴인 fork() 후 즉시 execve()를 따릅니다. 이를 통해 자식 프로세스는 깨끗한 상태에서 시작합니다. 새로운 주소 공간, 새 코드, 그리고 상속된 메모리 없음. 경계를 넘어가는 것은 명시적으로 전달된 파일 디스크립터뿐입니다.
명시적 상속: 상속되는 것은 리스닝 소켓과 통신 파이프뿐입니다. 다른 파일 디스크립터는 CLOEXEC 플래그로 닫힙니다. 이를 통해 민감한 핸들이 실수로 누출되는 것을 방지합니다.
seccomp 호환성: seccomp 필터를 사용하는 서비스는 fork()와 execve()를 허용해야 합니다. 이는 트레이드오프입니다. 우아한 재시작에는 이 syscall이 필요하므로 차단할 수 없습니다.
대부분의 네트워크 서비스에서는 이러한 트레이드오프가 수용 가능합니다. fork-exec 모델의 보안 특성은 잘 알려져 있고, NGINX나 Apache 같은 소프트웨어에서 수십 년간 실전 검증을 거쳤습니다.
실용적인 예제를 보겠습니다. 아래는 우아한 재시작을 지원하는 단순화된 TCP 에코 서버입니다.
rustuse ecdysis::tokio_ecdysis::{SignalKind, StopOnShutdown, TokioEcdysisBuilder}; use tokio::{net::TcpStream, task::JoinSet}; use futures::StreamExt; use std::net::SocketAddr; #[tokio::main] async fn main() { // ecdysis 빌더 생성 let mut ecdysis_builder = TokioEcdysisBuilder::new( SignalKind::hangup() // SIGHUP에서 업그레이드/리로드 트리거 ).unwrap(); // SIGUSR1에서 중지 트리거 ecdysis_builder .stop_on_signal(SignalKind::user_defined1()) .unwrap(); // 리스닝 소켓 생성 - 자식 프로세스가 상속받음 let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap(); let stream = ecdysis_builder .build_listen_tcp(StopOnShutdown::Yes, addr, |builder, addr| { builder.set_reuse_address(true)?; builder.bind(&addr.into())?; builder.listen(128)?; Ok(builder.into()) }) .unwrap(); // 연결 처리 태스크 생성 let server_handle = tokio::spawn(async move { let mut stream = stream; let mut set = JoinSet::new(); while let Some(Ok(socket)) = stream.next().await { set.spawn(handle_connection(socket)); } set.join_all().await; }); // 준비 완료를 알리고 종료를 대기 let (_ecdysis, shutdown_fut) = ecdysis_builder.ready().unwrap(); let shutdown_reason = shutdown_fut.await; log::info!("Shutting down: {:?}", shutdown_reason); // 연결을 우아하게 드레인 server_handle.await.unwrap(); } async fn handle_connection(mut socket: TcpStream) { // 에코 로직 구현 }
핵심 포인트:
build_listen_tcp는 자식 프로세스가 상속받을 리스너를 생성합니다.
ready()는 초기화가 완료되었고 부모 프로세스가 안전하게 종료(드레인)로 넘어가도 된다는 신호를 보냅니다.
shutdown_fut.await는 업그레이드 또는 중지가 요청될 때까지 블록합니다. 이 future는 업그레이드/리로드가 성공적으로 수행되었거나 종료 시그널을 받는 등, 프로세스가 종료되어야 할 때 단 한 번만 완료됩니다.
이 프로세스에 SIGHUP를 보내면, ecdysis는 다음을 수행합니다…
…부모 프로세스에서는:
바이너리의 새 인스턴스를 fork하고 exec합니다.
리스닝 소켓을 자식에게 전달합니다.
자식이 ready()를 호출할 때까지 기다립니다.
기존 연결을 드레인한 뒤 종료합니다.
…자식 프로세스에서는:
부모와 동일한 실행 흐름으로 초기화하지만, ecdysis가 소유한 소켓은 상속되므로 자식이 새로 바인딩하지 않습니다.
ready()를 호출해 부모에 준비 완료를 알립니다.
종료 또는 업그레이드 시그널을 기다리며 블록합니다.
ecdysis는 2021년부터 Cloudflare 프로덕션에서 동작해 왔습니다. 120개 이상의 국가에 있는 330개 이상의 데이터 센터에 배포된 핵심 Rust 인프라 서비스를 구동합니다. 이 서비스들은 하루 수십억 건의 요청을 처리하며, 보안 패치, 기능 릴리스, 설정 변경을 위해 빈번한 업데이트가 필요합니다.
ecdysis로 수행되는 재시작은, 순진한 stop/start 사이클에서 떨어졌을 수 있는 수십만 건의 요청을 매번 절약합니다. 전 세계 풋프린트 전체로 보면 이는 수백만 개의 연결을 보존하고 고객 신뢰성을 높이는 결과로 이어집니다.
여러 생태계에 우아한 재시작 라이브러리가 존재합니다. ecdysis를 언제 사용하고 대안을 언제 선택할지 이해하는 것은 올바른 도구를 고르는 데 중요합니다.
tableflip은 ecdysis에 영감을 준 Cloudflare의 Go 라이브러리입니다. Go 서비스에 대해 동일한 fork-and-inherit 모델을 구현합니다. Go가 필요하다면 tableflip은 훌륭한 선택입니다.
shellflip은 Cloudflare의 또 다른 Rust 우아한 재시작 라이브러리로, Rust 기반 프록시인 Oxy를 위해 특별히 설계되었습니다. shellflip은 더 강한 전제를 둡니다. systemd와 Tokio를 가정하고, 부모와 자식 사이에 임의의 애플리케이션 상태를 전달하는 데 집중합니다. 그 덕분에 복잡한 상태ful 서비스나, 너무 공격적인 샌드박싱을 적용해 스스로 소켓도 열 수 없는 서비스에는 탁월하지만, 단순한 케이스에는 오버헤드가 추가될 수 있습니다.
ecdysis는 5년간 프로덕션에서 단련된 우아한 재시작 기능을 Rust 생태계에 제공합니다. Cloudflare 글로벌 네트워크 전반에서 수백만 연결을 보호해 온 바로 그 기술이 이제 오픈 소스로 공개되어 누구나 사용할 수 있습니다.
전체 문서는 docs.rs/ecdysis에서 확인할 수 있으며, API 레퍼런스, 일반적인 사용 사례를 위한 예제, systemd 통합 단계가 포함되어 있습니다.
리포지토리의 examples 디렉터리에는 TCP 리스너, Unix 소켓 리스너, systemd 통합을 보여주는 작동 코드가 들어 있습니다.
이 라이브러리는 Argo Smart Routing & Orpheus 팀이 주도적으로 유지보수하고 있으며, Cloudflare 전사 팀들이 기여하고 있습니다. GitHub에서 기여, 버그 리포트, 기능 요청을 환영합니다.
고성능 프록시, 장기 실행 API 서버, 또는 가동 시간이 중요한 어떤 네트워크 서비스든 ecdysis는 무중단 운영을 위한 기반을 제공할 수 있습니다.
시작하기: github.com/cloudflare/ecdysis
Cloudflare의 커넥티비티 클라우드는 기업 네트워크 전체를 보호하고, 고객이 인터넷 규모 애플리케이션을 효율적으로 구축할 수 있도록 돕고, 모든 웹사이트 또는 인터넷 애플리케이션을 가속하며, DDoS 공격을 방어하고, 해커를 차단하며, Zero Trust 여정을 지원할 수 있습니다.
어떤 기기에서든 1.1.1.1에 방문해 인터넷을 더 빠르고 안전하게 만드는 무료 앱을 시작해 보세요.
더 나은 인터넷을 만들기 위한 우리의 미션에 대해 더 알아보려면 여기서 시작하세요. 새로운 커리어를 찾고 있다면 채용 공고를 확인해 보세요.