Published on

Rust tokio runtime dropped 패닉 원인·해결

Authors

서버나 워커를 Rust tokio로 작성하다 보면 어느 순간 아래와 비슷한 메시지로 패닉이 터질 때가 있습니다.

  • thread '...' panicked at '... runtime dropped ...'
  • Cannot drop a runtime in a context where blocking is not allowed
  • A Tokio 1.x runtime was dropped from within an asynchronous context

이 글은 이 문제를 단순히 "런타임이 먼저 죽었다"로 끝내지 않고, 어떤 코드 구조에서, , 어떻게 재현되며, 어떤 방식으로 고치는 게 안전한지를 패턴 중심으로 정리합니다.

비슷하게 "타임아웃이나 런타임 조건이 바뀌면서 갑자기 터지는" 유형의 장애 대응 감각이 필요하다면, 원인 재현과 해결을 체계적으로 다룬 글도 함께 참고하면 좋습니다: OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드

Tokio runtime dropped 패닉이 의미하는 것

Tokio 런타임은 크게 다음을 책임집니다.

  • 비동기 태스크 스케줄링(spawn된 작업 실행)
  • 타이머/IO 드라이버(소켓, DNS, 타임아웃 등)
  • spawn_blocking용 블로킹 스레드풀

따라서 런타임이 드롭(drop)되는 시점에 아직 다음이 남아 있다면 문제가 됩니다.

  • 런타임 컨텍스트를 필요로 하는 코드가 실행 중
  • 런타임이 관리하는 드라이버/스레드가 종료 중인데, 그 안에서 다시 런타임을 정리하려 함
  • 비동기 컨텍스트에서 런타임을 새로 만들거나(혹은 드롭) 블로킹 작업을 잘못 수행

핵심은 런타임의 수명(lifetime)비동기 작업의 수명이 어긋나는 것입니다.

가장 흔한 원인 1: 비동기 컨텍스트에서 런타임을 만들고 드롭함

예를 들어 아래처럼 async fn 안에서 Runtime::new()를 만들면, 함수가 끝날 때 런타임이 드롭됩니다. 문제는 그 함수가 이미 Tokio 런타임 위에서 실행 중일 수 있다는 점입니다.

재현 코드

use tokio::runtime::Runtime;

async fn bad() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        println!("nested runtime");
    });
}

#[tokio::main]
async fn main() {
    bad().await;
}

이 패턴은 다음을 동시에 위반할 가능성이 큽니다.

  • 이미 런타임 컨텍스트인데 또 런타임을 중첩 생성
  • block_on을 런타임 내부에서 호출
  • 스코프를 벗어나며 런타임이 비동기 컨텍스트에서 드롭

해결

  • 런타임은 보통 프로세스 진입점에서 한 번만 만들고(#[tokio::main] 또는 Runtime::new()), 그 안에서 모든 async를 실행합니다.
  • 특정 라이브러리가 block_on을 요구한다면, 호출 위치를 런타임 바깥(동기 컨텍스트) 으로 옮기거나, 구조를 바꿔 await 기반으로 맞춥니다.

권장 구조는 아래 둘 중 하나입니다.

패턴 A: #[tokio::main] 사용

#[tokio::main]
async fn main() {
    run().await;
}

async fn run() {
    // 모든 비동기 로직은 여기서 await
}

패턴 B: 명시적으로 런타임 생성(동기 main)

use tokio::runtime::Runtime;

fn main() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        run().await;
    });
}

async fn run() {
    // ...
}

가장 흔한 원인 2: Drop 구현에서 비동기/블로킹 정리를 시도

Rust에서는 자원 정리를 위해 Drop을 구현하는 경우가 많습니다. 하지만 Dropasync가 될 수 없고, Tokio 환경에서는 Drop 시점이 런타임 내부일 수도 있습니다.

대표적인 위험 패턴은 다음입니다.

  • Drop에서 tokio::runtime::Handle::current()를 잡고 block_on 유사 동작을 하려는 시도
  • Drop에서 spawn_blocking 또는 block_in_place 같은 블로킹 전환을 시도
  • Drop에서 채널 종료 후 태스크 JoinHandle을 기다리는 동작을 동기적으로 수행

재현에 가까운 예시

use tokio::runtime::Handle;

struct Client {
    // 예: 백그라운드 태스크, 커넥션 등
}

impl Drop for Client {
    fn drop(&mut self) {
        // 위험: Drop에서 현재 런타임에 의존
        let _h = Handle::current();
        // 여기서 어떤 "기다림"을 시도하면 런타임 드롭/블로킹 관련 패닉이 날 수 있음
    }
}

#[tokio::main]
async fn main() {
    let _c = Client {};
}

해결: Drop은 "신호"만 보내고, 진짜 정리는 async fn shutdown()에서

가장 안전한 패턴은 다음입니다.

  • Drop에서는 즉시 반환 가능한 최소 작업만 수행(예: 종료 플래그 설정, 채널 close)
  • 종료를 보장해야 한다면, 호출자가 명시적으로 shutdown().await를 호출하게 설계
use tokio::task::JoinHandle;
use tokio::sync::oneshot;

struct Worker {
    tx: Option<oneshot::Sender<()>>,
    handle: Option<JoinHandle<()>>,
}

impl Worker {
    fn new() -> Self {
        let (tx, rx) = oneshot::channel();
        let handle = tokio::spawn(async move {
            let _ = rx.await;
            // 리소스 정리 로직
        });
        Self { tx: Some(tx), handle: Some(handle) }
    }

    async fn shutdown(mut self) {
        if let Some(tx) = self.tx.take() {
            let _ = tx.send(());
        }
        if let Some(handle) = self.handle.take() {
            let _ = handle.await;
        }
    }
}

impl Drop for Worker {
    fn drop(&mut self) {
        // Drop에서는 "종료 신호" 정도만 시도하고 끝내기
        if let Some(tx) = self.tx.take() {
            let _ = tx.send(());
        }
    }
}

#[tokio::main]
async fn main() {
    let w = Worker::new();
    w.shutdown().await;
}

이렇게 하면 런타임 수명과 종료 순서를 호출자가 통제할 수 있어, "런타임이 먼저 죽어서 정리 실패" 같은 문제를 크게 줄일 수 있습니다.

가장 흔한 원인 3: spawn한 태스크가 런타임보다 오래 살도록 만들어짐

아래 상황에서 자주 터집니다.

  • main이 너무 빨리 종료되어 런타임이 드롭되는데, 백그라운드 태스크는 아직 실행 중
  • 라이브러리/서비스 객체가 tokio::spawn으로 태스크를 올려두고, 그 JoinHandle을 보관하지 않음

재현 코드

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
            println!("still running");
        }
    });

    // main이 바로 끝나면 런타임이 드롭
}

이 경우는 패닉이 안 나고 조용히 종료되기도 하지만, 리소스 정리/드롭 경로에서 런타임 의존 코드가 있으면 "runtime dropped" 류 패닉으로 이어질 수 있습니다.

해결: JoinHandle을 보관하고 종료 시점에 await 또는 취소

use tokio::task::JoinHandle;

#[tokio::main]
async fn main() {
    let handle: JoinHandle<()> = tokio::spawn(async {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        }
    });

    // 서비스 로직 수행
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    // 종료: abort 후 join(에러는 무시하거나 로깅)
    handle.abort();
    let _ = handle.await;
}

프로덕션에서는 보통 CancellationToken(예: tokio-util)을 붙여서 "정상 종료"를 구현합니다.

가장 흔한 원인 4: std::thread::spawn에서 Tokio API를 호출

Tokio API는 대체로 "현재 런타임 컨텍스트"를 요구합니다. 그런데 OS 스레드를 직접 만들고 그 안에서 tokio::spawn 같은 걸 호출하면, 그 스레드에는 런타임이 없어서 문제가 됩니다.

잘못된 예

fn main() {
    std::thread::spawn(|| {
        // 여긴 Tokio 런타임 컨텍스트가 없음
        tokio::spawn(async {
            println!("hi");
        });
    }).join().unwrap();
}

해결 1: 스레드 안에서 Runtime을 만들고 그 스레드에서만 사용

use tokio::runtime::Runtime;

fn main() {
    std::thread::spawn(|| {
        let rt = Runtime::new().unwrap();
        rt.block_on(async {
            tokio::spawn(async {
                println!("hi");
            }).await.unwrap();
        });
    }).join().unwrap();
}

해결 2: 런타임 Handle을 전달하고 spawn은 그 핸들로

use tokio::runtime::Handle;

#[tokio::main]
async fn main() {
    let handle = Handle::current();

    std::thread::spawn(move || {
        handle.spawn(async {
            println!("hi from handle");
        });
    }).join().unwrap();
}

단, 이 경우에도 런타임이 먼저 종료되면 핸들로 스폰한 태스크가 의미 없거나, 종료 경로에서 문제를 만들 수 있으니, 종료 순서를 설계해야 합니다.

가장 흔한 원인 5: 테스트 코드에서 런타임 수명이 꼬임

#[tokio::test]는 테스트마다 런타임을 만들어줍니다. 여기서 전역 싱글톤/정적 변수에 Tokio 객체(예: Handle, Client 내부 태스크)를 저장하면, 테스트 종료 시 런타임이 드롭되며 다음 테스트에서 이상한 패닉이 터질 수 있습니다.

해결 체크리스트

  • 전역(static)으로 Tokio 의존 객체를 저장하지 않기
  • 테스트 간 공유가 필요하면 프로세스 단일 런타임을 쓰는 테스트 하네스 고려
  • 테스트 종료 전에 shutdown().await 같은 명시적 종료 수행

실전 디버깅: 어디서 런타임이 드롭되는지 찾는 방법

1) 패닉 스택트레이스 확보

  • 로컬에서는 RUST_BACKTRACE=1 또는 RUST_BACKTRACE=full로 실행
  • 컨테이너/쿠버네티스 환경에서는 동일한 환경변수를 매니페스트에 추가

2) 드롭 순서 로깅

  • 중요한 객체의 Drop에 로그를 넣어 "누가 먼저 죽는지" 확인
  • 백그라운드 태스크 시작/종료 시점도 함께 로깅

3) 종료 시퀀스 설계

서버라면 보통 다음 순서가 안전합니다.

  1. 종료 신호 수신(SIGTERM 등)
  2. 신규 요청/작업 수락 중단
  3. 백그라운드 태스크에 취소/종료 신호
  4. JoinHandleawait하여 정리 완료 확인
  5. 마지막에 main 종료(런타임 드롭)

이건 "캐시/리밸리데이트 충돌"처럼 종료 조건이 얽혀 장애가 나는 케이스와도 유사합니다. 종료 순서와 책임 경계를 명확히 하는 접근은 다른 런타임 이슈에도 도움이 됩니다: Next.js ISR 500 - revalidate·캐시 충돌 해결

추천 해결 패턴 요약

패턴 1: 런타임은 진입점에서 하나만

  • #[tokio::main] 선호
  • 라이브러리 코드에서 런타임 생성/드롭을 숨기지 말기

패턴 2: 백그라운드 태스크는 반드시 "소유"하고 "종료"할 것

  • JoinHandle을 구조체에 저장
  • shutdown().await 제공
  • Drop은 최후의 안전망(신호만)으로 최소화

패턴 3: 블로킹은 spawn_blocking으로, 단 종료를 고려

  • CPU/블로킹 IO는 tokio::task::spawn_blocking으로 격리
  • 종료 시점에 해당 작업이 어떻게 끝나는지(취소 가능한지) 설계

패턴 4: 스레드와 런타임 경계를 명확히

  • std::thread::spawn 내부에서 Tokio를 쓰려면 Handle 전달 또는 전용 런타임
  • 런타임 수명보다 오래 사는 스레드/태스크를 만들지 않기

결론

tokio runtime dropped 패닉은 단순한 "Tokio 버그"가 아니라, 대부분 수명/종료 순서/컨텍스트(동기 vs 비동기) 설계 문제에서 발생합니다. 특히 Drop에서 정리를 시도하거나, 태스크를 스폰해놓고 소유하지 않는 구조에서 재현이 쉽습니다.

정리하면 아래 2가지만 지켜도 재발률이 크게 줄어듭니다.

  • 런타임은 한 번 만들고, 런타임이 살아있는 동안 태스크를 확실히 종료한다
  • 정리는 Drop이 아니라 shutdown().await 같은 명시적 프로토콜로 수행한다

이 원칙으로 코드를 재구성한 뒤에도 여전히 패닉이 난다면, 스택트레이스와 함께 "어떤 객체의 Drop에서 무엇을 하려 했는지"를 먼저 의심해보는 것이 가장 빠른 접근입니다.