Published on

Rust Tokio runtime dropped 패닉 원인·해결

Authors

서버나 배치 작업을 Rust + Tokio로 운영하다 보면 어느 날 갑자기 아래처럼 터지는 패닉을 만날 수 있습니다.

thread '...' panicked at '...: cannot start a runtime from within a runtime'

혹은 더 직설적으로:

thread '...' panicked at 'runtime dropped the runtime'

메시지는 비슷해 보여도 실제 원인은 대개 런타임의 수명(lifetime)과 Drop 시점, 그리고 어떤 스레드/컨텍스트에서 Tokio API를 호출했는지에 걸려 있습니다. 이 글에서는 현장에서 자주 보이는 패턴을 기준으로 원인을 분류하고, “재발 방지” 관점의 해결책을 제시합니다.

> 운영 환경에서 장애가 간헐적으로만 재현된다면, 종료/시그널/그레이스풀 셧다운과도 얽혀 있는 경우가 많습니다. Kubernetes에서 종료가 꼬이는 문제를 함께 다루는 글로는 Kubernetes Pod가 Terminating에 멈출 때 - finalizer·grace·SIGTERM 실전 디버깅도 참고할 만합니다.

1) 'runtime dropped'는 정확히 뭘 의미하나

Tokio 런타임은 내부적으로 스케줄러, 타이머, I/O 드라이버, 작업 큐 등을 소유합니다. 그리고 Runtime이 Drop 되는 순간, 런타임이 관리하던 리소스는 정리됩니다.

문제는 다음 상황입니다.

  • 아직 실행 중인 task가 있는데 런타임이 먼저 Drop 됨
  • 런타임이 Drop 된 이후에 tokio::spawn, sleep, TcpStream::connect 같은 “런타임이 필요한” 동작이 수행됨
  • 혹은 런타임 안에서 또 런타임을 만들거나(Runtime::new, #[tokio::main] 중첩), 블로킹 경계가 꼬여 런타임 컨텍스트가 깨짐

결국 핵심은 하나입니다.

> Tokio API를 호출하는 시점에 유효한 런타임 컨텍스트가 존재해야 한다.

2) 가장 흔한 원인 5가지

원인 A: 임시 Runtime를 만들고 바로 Drop

아래는 테스트/유틸 코드에서 특히 흔합니다.

use tokio::runtime::Runtime;

fn do_async_work_sync() {
    let rt = Runtime::new().unwrap();

    // block_on이 끝나면 rt는 스코프를 벗어나며 Drop
    rt.block_on(async {
        tokio::spawn(async {
            // spawn된 태스크는 block_on 종료 이후에도 계속 돌 수 있음
            // 그런데 rt가 Drop되면 이 태스크는 갈 곳이 없어짐
            println!("background task");
        });

        // 여기서 바로 리턴하면 spawn 태스크가 완료되기 전에 block_on이 끝남
    });
}

block_on은 현재 future가 끝날 때까지만 돌려줍니다. 그 안에서 spawn한 태스크가 끝났는지까지 보장하지 않습니다. block_on이 끝나고 rt가 Drop되면, 백그라운드 태스크가 런타임을 참조하는 순간 패닉/오류로 이어질 수 있습니다.

해결 패턴

  • spawn한 태스크의 JoinHandle을 모아 await하거나
  • 백그라운드 태스크를 “런타임과 같은 수명”으로 유지되도록 구조화
use tokio::runtime::Runtime;

fn do_async_work_sync() {
    let rt = Runtime::new().unwrap();

    rt.block_on(async {
        let h = tokio::spawn(async {
            println!("background task");
        });

        // 반드시 기다린다
        h.await.unwrap();
    });
}

원인 B: #[tokio::main] 안에서 또 런타임 생성/block_on

런타임 내부에서 또 런타임을 돌리면 대표적으로 다음 메시지가 납니다.

  • cannot start a runtime from within a runtime

예:

#[tokio::main]
async fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        println!("nested");
    });
}

해결 패턴

  • “동기 함수에서 비동기를 돌리기 위해 runtime을 만든다”는 발상을 버리고
  • 가능한 최상단(프로세스 엔트리포인트)에서만 런타임을 만들고 아래로는 async를 전파
#[tokio::main]
async fn main() {
    run().await;
}

async fn run() {
    println!("single runtime");
}

원인 C: Drop 중(또는 Drop 이후)에 Tokio를 호출

종종 Drop 구현에서 로그 flush, 네트워크 종료, 비동기 정리 등을 하다가 런타임 컨텍스트가 없어서 터집니다.

struct Client;

impl Drop for Client {
    fn drop(&mut self) {
        // Drop은 async가 될 수 없고,
        // 여기서 tokio API를 호출하면 컨텍스트가 없을 수 있음
        let _ = tokio::runtime::Handle::try_current().unwrap();
    }
}

해결 패턴

  • Drop에서 비동기 정리를 하지 말고, 명시적인 shutdown().await를 제공
  • 종료 시퀀스에서 shutdown().await를 호출한 뒤 Drop되도록 설계
struct Client;

impl Client {
    async fn shutdown(self) {
        // 비동기 정리는 여기서
    }
}

#[tokio::main]
async fn main() {
    let client = Client;
    client.shutdown().await;
    // 여기서 client는 소비(consumed)되어 Drop 문제가 줄어듦
}

원인 D: spawn한 태스크가 프로그램 종료/시그널 처리보다 오래 삶

서버에서 SIGTERM을 받았을 때 그레이스풀 셧다운을 제대로 하지 않으면, 런타임이 내려가는 동안에도 태스크가 I/O/타이머를 호출하다가 문제가 납니다.

해결 패턴(권장)

  • tokio::select! + broadcast/watch/CancellationToken으로 종료 신호를 전파
  • 모든 워커 태스크의 JoinHandle을 수집하고 종료를 기다림

아래는 tokio-utilCancellationToken을 쓴 예시입니다.

use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();
    let mut set = JoinSet::new();

    // 워커들
    for i 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(200)) => {
                        // 주기 작업
                        let _ = i;
                    }
                }
            }
        });
    }

    // 종료 트리거(예: SIGINT)
    tokio::spawn({
        let t = token.clone();
        async move {
            let _ = tokio::signal::ctrl_c().await;
            t.cancel();
        }
    });

    // 모든 워커가 끝날 때까지 기다림
    while let Some(res) = set.join_next().await {
        res.unwrap();
    }
}

이 패턴의 장점은 “런타임이 Drop되기 전에 태스크가 스스로 내려오게” 만든다는 점입니다.

원인 E: 블로킹 코드가 런타임 스레드를 잠가 타이밍이 꼬임

std::thread::sleep, 무거운 CPU 작업, 동기 I/O를 async 컨텍스트에서 그대로 수행하면 런타임 스케줄링이 망가지고, 종료 타이밍도 꼬이면서 런타임 Drop 시점에 예외가 도드라질 수 있습니다.

해결 패턴

  • CPU/블로킹 I/O는 spawn_blocking으로 격리
#[tokio::main]
async fn main() {
    let v = tokio::task::spawn_blocking(|| {
        // 무거운 연산/블로킹 호출
        std::thread::sleep(std::time::Duration::from_secs(1));
        42
    })
    .await
    .unwrap();

    println!("{v}");
}

3) 재현 가능한 최소 예제(MRE)로 원인 좁히기

현업에서 가장 빠른 진단법은 “런타임 수명 문제인가?”를 확인하는 것입니다.

체크리스트

  • Runtime::new()를 여러 군데서 만들고 있나?
  • 라이브러리 코드에서 block_on을 호출하나?
  • Drop에서 tokio API를 호출하나?
  • spawnJoinHandle을 버리나?
  • 종료 시그널을 받아도 태스크를 정리하지 않고 프로세스가 끝나나?

특히 라이브러리에서 런타임을 만들거나 block_on을 호출하면, 상위 애플리케이션(이미 Tokio 런타임을 가진)과 충돌하기 쉽습니다. 라이브러리는 보통 async fn만 제공하고 런타임은 바이너리(엔트리포인트)에서만 책임지는 구조가 안전합니다.

4) 구조적 해결: “런타임은 하나, 종료는 명시적으로”

실무에서 안전한 구조는 아래 원칙으로 요약됩니다.

  1. 런타임 생성은 최상단(보통 main)에서 한 번만
  2. 비동기는 끝까지 비동기로 전파(async fn)
  3. 백그라운드 태스크는 반드시 종료 경로를 갖게 하고 JoinHandle을 회수
  4. 리소스 정리는 Drop이 아니라 shutdown().await 같은 명시적 API로

예시(서버 뼈대):

use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;

struct App {
    token: CancellationToken,
}

impl App {
    fn new() -> Self {
        Self { token: CancellationToken::new() }
    }

    async fn run(&self) {
        let mut set = JoinSet::new();

        // 예: 워커
        for _ in 0..2 {
            let t = self.token.clone();
            set.spawn(async move {
                loop {
                    tokio::select! {
                        _ = t.cancelled() => break,
                        _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
                    }
                }
            });
        }

        // 종료 신호 대기
        tokio::select! {
            _ = tokio::signal::ctrl_c() => {
                self.token.cancel();
            }
        }

        while let Some(r) = set.join_next().await {
            r.unwrap();
        }
    }

    async fn shutdown(&self) {
        self.token.cancel();
    }
}

#[tokio::main]
async fn main() {
    let app = App::new();
    app.run().await;
    app.shutdown().await;
}

5) 운영 환경에서 더 자주 터지는 이유(컨테이너/오케스트레이션)

로컬에서는 잘 안 터지는데 운영에서만 터진다면, 보통 다음이 겹칩니다.

  • SIGTERM 이후 grace period 내에 종료되지 못해 강제 종료
  • readiness/liveness 실패로 프로세스가 예고 없이 재시작
  • 로그/네트워크 flush가 종료 타이밍에 몰리며 Drop 경로가 복잡해짐

이때는 애플리케이션 코드만 보지 말고 “종료가 정상적으로 진행되는지”를 같이 봐야 합니다. 종료가 지저분하면 런타임 Drop 시점에 살아 있는 태스크가 늘어나고, 결과적으로 runtime dropped류 문제가 더 자주 드러납니다. 쿠버네티스 종료 디버깅은 위에서 언급한 Pod Terminating 디버깅 글이 도움이 됩니다.

6) 결론: 패닉을 ‘없애는’ 가장 확실한 방법

Tokio의 runtime dropped 패닉은 “Tokio가 불안정해서”가 아니라, 대부분 런타임 수명 관리가 애매한 구조에서 발생합니다. 아래 3가지만 지키면 재발률이 급격히 내려갑니다.

  • 런타임은 한 번만 만들고(보통 #[tokio::main]) 중첩하지 않는다
  • spawn한 태스크는 종료 신호를 받고 스스로 내려오게 하며, JoinHandle을 회수한다
  • Drop에서 비동기 작업을 하지 말고 shutdown().await로 명시적으로 닫는다

이 원칙대로 코드 구조를 정리하면, 에러 메시지를 “잡아내는” 수준을 넘어 런타임/태스크의 생명주기가 예측 가능해지고 운영 안정성이 크게 좋아집니다.