- Published on
Rust Tokio runtime dropped 패닉 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 워커를 Tokio로 구성하다 보면 다음과 같은 메시지로 프로세스가 죽는 경우가 있습니다.
thread '...' panicked at 'runtime dropped the context'
이 패닉은 “Tokio 런타임 컨텍스트가 필요한데, 이미 런타임이 drop(해제)되었다”는 뜻입니다. 즉, 비동기 작업이 아직 실행 중이거나, 런타임에 의존하는 객체가 남아있는데 런타임이 먼저 종료된 상황입니다.
Tokio는 런타임이 스레드 로컬 컨텍스트로 현재 실행 환경을 관리하는데, 그 컨텍스트가 사라진 시점에 tokio::spawn, sleep, I/O 등 런타임 의존 API가 호출되면 패닉이 날 수 있습니다.
이 글에서는 재현 패턴을 통해 원인을 분류하고, 실무에서 안전하게 고치는 방법(구조/Drop 순서/Shutdown/Join)을 정리합니다. 운영 환경에서 패닉이 CrashLoopBackOff로 이어지는 경우도 많으니, 쿠버네티스 관점의 증상 확인은 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅도 함께 참고하면 좋습니다.
1) 왜 발생하나: 핵심 원리 (컨텍스트 수명)
Tokio 런타임은 대략 다음을 보장합니다.
#[tokio::main]또는Runtime::block_on내부에서는 런타임 컨텍스트가 유효- 런타임이 drop되면 그 이후에는 런타임 의존 API 호출이 불가(일부는 에러, 일부는 패닉)
spawn한 태스크가 남아있어도 런타임이 종료되면 태스크는 더 이상 진행할 수 없음
따라서 문제는 대부분 “런타임보다 오래 살아버린 것”에서 시작합니다.
- 런타임 밖으로
JoinHandle/채널/클라이언트/타이머/백그라운드 태스크 핸들 등이 새어나감 Drop구현에서 async 동작(직접/간접)을 수행- 라이브러리 내부에서 별도 런타임을 생성하거나, 반대로 런타임이 필요하지만 현재 스레드에 런타임이 없음
2) 가장 흔한 원인 5가지와 해결책
원인 A. tokio::spawn을 런타임 밖에서 호출
가장 직관적인 케이스입니다. tokio::spawn은 현재 스레드에 활성 런타임이 있어야 합니다.
잘못된 예
fn start_background() {
tokio::spawn(async {
// ...
});
}
fn main() {
start_background(); // 런타임 없음 -> 패닉 가능
}
해결 1: 호출 위치를 async 컨텍스트로 옮기기
#[tokio::main]
async fn main() {
start_background().await;
}
async fn start_background() {
tokio::spawn(async {
// ...
});
}
해결 2: 런타임 핸들을 명시적으로 주입
use tokio::runtime::Handle;
fn start_background(handle: Handle) {
handle.spawn(async {
// ...
});
}
#[tokio::main]
async fn main() {
let handle = Handle::current();
start_background(handle);
}
핸들 주입은 라이브러리/레이어드 아키텍처에서 특히 유용합니다. “어디서 런타임을 얻는가”를 숨기지 않기 때문에, 런타임 수명 문제를 구조적으로 줄입니다.
원인 B. 런타임을 직접 만들고 block_on 후 태스크가 남아있음
Runtime::new()로 런타임을 만들고 block_on으로 잠깐 실행한 뒤 런타임을 drop하면, 그 사이 spawn된 태스크는 런타임 종료와 함께 붕 떠버립니다.
재현 예
use tokio::runtime::Runtime;
fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
println!("late");
});
// block_on이 끝나면 main은 rt drop으로 이동
});
// 여기서 rt drop -> 백그라운드 태스크는 런타임 상실
}
해결: 태스크를 추적하고 종료 시점에 join
use tokio::runtime::Runtime;
fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let h = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
println!("done");
});
// 종료 전에 join
let _ = h.await;
});
}
실무에서는 JoinHandle을 전부 들고 다니기 어렵습니다. 이때는 JoinSet을 사용하면 종료 시점에 일괄 join/abort가 가능해집니다.
use tokio::task::JoinSet;
#[tokio::main]
async fn main() {
let mut set = JoinSet::new();
for i in 0..10 {
set.spawn(async move {
println!("task {i}");
});
}
while let Some(res) = set.join_next().await {
res.unwrap();
}
}
원인 C. Drop에서 async 작업을 하려다 런타임을 건드림
Rust에서 Drop은 async가 될 수 없습니다. 그래서 흔히 Drop에서 다음 같은 “정리 작업”을 시도하다가 런타임 의존 API를 호출하게 됩니다.
Drop에서tokio::spawn으로 cleanup 태스크 실행Drop에서 채널로 종료 신호를 보내는데 그 과정에서 런타임 컨텍스트가 필요Drop에서reqwest/DB 클라이언트의 async close를 호출하려고 꼼수 사용
이때 객체의 drop 순서에 따라 런타임이 먼저 drop된 뒤 Drop이 실행되면 패닉이 터집니다.
해결: 명시적 shutdown().await 패턴으로 분리
use tokio::task::JoinHandle;
use tokio::sync::oneshot;
struct Worker {
stop_tx: Option<oneshot::Sender<()>>,
handle: JoinHandle<()>,
}
impl Worker {
fn start() -> Self {
let (stop_tx, mut stop_rx) = oneshot::channel();
let handle = tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut stop_rx => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_millis(200)) => {
// do work
}
}
}
});
Self { stop_tx: Some(stop_tx), handle }
}
async fn shutdown(mut self) {
if let Some(tx) = self.stop_tx.take() {
let _ = tx.send(());
}
let _ = self.handle.await;
}
}
#[tokio::main]
async fn main() {
let worker = Worker::start();
// ...
worker.shutdown().await; // 런타임이 살아있는 동안 정리
}
핵심은 “정리 작업을 Drop에 맡기지 말고, 런타임이 살아있을 때 await 가능한 API로 수행”입니다.
원인 D. 중첩 런타임/block_on 남용 (특히 라이브러리 코드)
이미 Tokio 런타임에서 실행 중인데, 라이브러리 함수 내부에서 또 Runtime::new().block_on(...)을 하는 경우가 있습니다. 이는 다음 문제를 유발합니다.
- 런타임 중첩으로 인한 데드락/패닉
- 현재 런타임과 별개 런타임을 만들어 태스크/리소스 수명이 꼬임
나쁜 예 (라이브러리에서 동기 API 제공하려고 block_on)
pub fn do_sync() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// ...
});
}
해결: async API를 노출하고, 동기 래퍼는 최상단에서만
pub async fn do_async() {
// ...
}
// 정말 필요하면 바이너리(main)에서만 동기 래핑
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(do_async());
}
또는 호출자가 이미 async 컨텍스트인지 감지해 다른 전략을 쓰는 방식도 있지만, 일반적으로는 API 설계에서 async/ sync 경계를 명확히 하는 것이 안전합니다.
원인 E. spawn한 태스크가 panic/abort되고, 그 여파가 런타임 종료 타이밍을 당김
직접적인 “runtime dropped” 원인은 아니지만, 백그라운드 태스크가 에러로 조기 종료되면서 메인 태스크가 빠르게 끝나 런타임이 drop되고, 뒤늦게 다른 코드가 런타임 의존 API를 호출하며 패닉으로 이어지는 케이스가 있습니다.
해결 체크리스트
- 중요한 태스크는
JoinHandle을 보관하고.await해서 실패를 관측 tokio::select!로 종료 조건을 명시하고, 종료 시그널을 중앙집중화- 태스크 내 panic을 로깅/전파(예:
handle.await?)
let handle = tokio::spawn(async {
// ...
anyhow::Result::<()>::Ok(())
});
match handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => eprintln!("task error: {e:?}"),
Err(join_err) => eprintln!("task panicked/aborted: {join_err:?}"),
}
3) 구조적으로 안전한 종료(Shutdown) 설계
Tokio 앱에서 런타임 수명 문제를 없애려면 “런타임이 살아있는 동안 모든 비동기 리소스를 정리”할 수 있어야 합니다. 실무적으로는 아래 패턴이 가장 안정적입니다.
3.1 CancellationToken + JoinSet 조합
여러 워커 태스크를 띄우고, 종료 시 한 번에 취소/조인합니다.
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
#[tokio::main]
async fn main() {
let token = CancellationToken::new();
let mut set = JoinSet::new();
for _ in 0..4 {
let t = token.clone();
set.spawn(async move {
loop {
tokio::select! {
_ = t.cancelled() => break,
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
// work
}
}
}
});
}
// 예: SIGINT 받으면 종료
tokio::signal::ctrl_c().await.unwrap();
token.cancel();
while let Some(res) = set.join_next().await {
res.unwrap();
}
}
이 패턴은 Drop에 정리를 맡기지 않고, “종료 시점”을 명시적으로 만들기 때문에 runtime dropped 류의 문제를 크게 줄입니다.
3.2 리소스 소유권(ownership)을 런타임 내부에 가두기
- 런타임 밖(동기 영역)으로
JoinHandle, async 클라이언트, 채널 receiver 등을 빼지 않기 - 필요한 경우에는
Handle을 주입해 런타임 의존성을 명시
특히 라이브러리 레이어에서 “전역 싱글턴 런타임”을 만들고 여기저기서 spawn하는 설계는, 테스트/프로세스 종료/Drop 순서에서 예측 불가능한 문제가 생기기 쉽습니다.
4) 디버깅: 어디서 런타임이 drop되었는지 찾는 법
4.1 패닉 백트레이스 확보
RUST_BACKTRACE=1 cargo run
# 또는 더 자세히
RUST_BACKTRACE=full cargo run
백트레이스에서 tokio::runtime 관련 프레임 위쪽을 보면 “런타임이 필요한 API를 호출한 위치”가 나옵니다. 그 호출이 런타임 외부인지, Drop 중인지부터 확인합니다.
4.2 로그로 수명 추적
- 런타임 생성/종료 지점에 로그
- 워커 시작/종료 지점에 로그
shutdown().await호출 여부 로그
운영에서 패닉이 반복되면 컨테이너가 재시작되며 원인 파악이 더 어려워집니다. 이때는 CrashLoopBackOff 상황에서 이벤트/로그/Probe를 같이 보는 것이 도움이 됩니다: Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅
5) 자주 묻는 함정 Q&A
Q1. std::thread::spawn 안에서 tokio::spawn하면 왜 터지나요?
std::thread::spawn으로 만든 스레드는 기본적으로 Tokio 런타임 컨텍스트가 없습니다. 해결은 다음 중 하나입니다.
- 해당 스레드에서
Runtime을 만들고 그 안에서만 async 수행 - 또는
Handle을 전달해handle.spawn(...)사용
Q2. Drop에서 꼭 정리해야 하는데요?
가능하면 Drop에서는 동기적이고 런타임이 필요 없는 정리만 하세요(파일 디스크립터 close 등). 네트워크 flush/DB close 같은 async 정리는 shutdown().await로 분리하는 것이 정석입니다.
Q3. 테스트에서만 가끔 터집니다
테스트는 특히 수명이 짧고 병렬 실행이 많아 문제를 빨리 드러냅니다.
#[tokio::test]에서 태스크를 spawn만 하고 await하지 않는지- 전역 상태(OnceCell 등)에 런타임 의존 객체를 저장해두고 테스트 종료 시 drop되는지
- 테스트 런타임(flavor, worker_threads) 차이로 타이밍이 바뀌는지
부터 점검하세요.
6) 체크리스트(실무용)
-
tokio::spawn이 항상 런타임 컨텍스트 안에서 호출되는가? (또는Handle로 호출하는가?) -
Runtime::block_on으로 감싼 블록에서 spawn한 태스크를 join/abort하고 나오는가? -
Drop에서 async 작업(직접/간접)을 하지 않는가? - 종료 경로가 명시적인가? (
shutdown().await,CancellationToken,JoinSet) - 중요한 태스크의 실패를 관측하고 있는가? (
JoinHandle.await)
마무리
Tokio의 runtime dropped 패닉은 단순히 “Tokio가 불안정해서”가 아니라, 대부분 런타임 수명과 태스크/리소스 수명이 엇갈린 설계 문제에서 발생합니다. 해결의 핵심은 다음 두 가지입니다.
- 런타임 밖에서 런타임 의존 API를 호출하지 않기(필요하면
Handle주입) - Drop에 정리를 맡기지 말고, 런타임이 살아있을 때
shutdown().await로 종료를 완결하기
위 패턴(명시적 shutdown, JoinSet/CancellationToken, join으로 실패 관측)을 적용하면 동일한 유형의 패닉을 재발 없이 제거할 수 있습니다.