Published on

Rust Tokio 런타임 패닉 원인 7가지와 해결법

Authors

서버나 워커를 Rust로 만들다 보면, 어느 순간 tokio 런타임에서 갑자기 패닉이 터지면서 프로세스가 종료되는 경험을 하게 됩니다. 특히 개발 환경에서는 잘 돌다가도, 테스트/CI 또는 컨테이너 환경에서만 재현되는 경우가 많아 원인 파악이 어렵습니다.

이 글은 Tokio 런타임 패닉을 “왜 나는지”를 증상 중심으로 7가지로 정리하고, 각 케이스별로 재현 예제와 해결책을 함께 제공합니다. 마지막에는 운영 환경에서 패닉을 빠르게 좁히는 체크리스트도 넣었습니다.

참고로, 런타임 패닉이 메모리 압박이나 컨테이너 OOM으로 보이는 경우도 있습니다. 컨테이너에서 Exit 137 같은 형태로 죽는다면 이 글도 같이 보면 도움이 됩니다: GitLab Runner Docker executor OOM·Exit 137 해결

1) 런타임 중첩 생성: Cannot start a runtime from within a runtime

전형적인 증상

  • 에러 메시지에 Cannot start a runtime from within a runtime 또는 유사 문구
  • 보통 라이브러리 내부에서 tokio::runtime::Runtime::new().block_on(...) 을 호출할 때 발생

재현 코드

use tokio::runtime::Runtime;

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

#[tokio::main]
async fn main() {
    // 이미 Tokio 런타임 안인데,
    // 또 런타임을 만들어 block_on 하면 패닉 가능
    let rt = Runtime::new().unwrap();
    rt.block_on(work());
}

해결책

  • 원칙: 애플리케이션 최상단(엔트리포인트)에서만 런타임을 만들고, 나머지는 async 를 전파합니다.
  • 라이브러리라면 block_on 제공을 지양하고 async fn API를 제공하세요.
  • 정말 동기 API가 필요하면, 호출자가 런타임 밖에서 사용하도록 문서화하거나, 별도 스레드에서 런타임을 구동하는 어댑터를 분리합니다.

권장 패턴

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

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

2) 런타임 컨텍스트 밖에서 Tokio 기능 호출: there is no reactor running

전형적인 증상

  • 메시지에 there is no reactor running 또는 not currently running on a Tokio runtime
  • tokio::spawn, tokio::time::sleep, TcpStream::connect 등을 런타임 밖에서 호출

재현 코드

fn main() {
    // 런타임 없이 Tokio 타이머를 사용하면 실패
    let _ = tokio::time::sleep(std::time::Duration::from_millis(10));
}

해결책

  • #[tokio::main] 또는 명시적 런타임을 통해 컨텍스트를 보장합니다.
  • 테스트에서는 #[tokio::test] 를 사용합니다.
#[tokio::main]
async fn main() {
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}

테스트 예시:

#[tokio::test]
async fn test_sleep() {
    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}

3) spawn한 태스크의 패닉이 JoinHandle로 전파됨

Tokio 자체가 “런타임이 패닉”이라기보다, 태스크 내부 패닉이 JoinError로 돌아오고 이를 unwrap() 하면서 프로세스가 죽는 형태가 매우 흔합니다.

전형적인 증상

  • 로그에 task panicked 또는 JoinError::Panic 관련 표시
  • 호출 코드에서 handle.await.unwrap() 같은 패턴

재현 코드

#[tokio::main]
async fn main() {
    let h = tokio::spawn(async {
        panic!("boom");
    });

    // 여기서 unwrap이 다시 패닉을 유발
    h.await.unwrap();
}

해결책

  • JoinHandle은 반드시 에러를 분기 처리합니다.
  • 태스크 경계에서 패닉이 나지 않도록 Result 기반으로 설계하고, 필요한 경우 catch_unwind도 고려합니다.
#[tokio::main]
async fn main() {
    let h = tokio::spawn(async {
        // panic 대신 Result로
        anyhow::Result::<()>::Ok(())
    });

    match h.await {
        Ok(Ok(())) => {}
        Ok(Err(e)) => eprintln!("task error: {e:#}"),
        Err(join_err) => eprintln!("task join error: {join_err}"),
    }
}

4) 블로킹 작업을 런타임 워커 스레드에서 실행 (교착·타임아웃·연쇄 실패)

이 케이스는 즉시 패닉이 아니라, 시간이 지나면서 타임아웃/리소스 고갈을 유발하고 결국 다른 곳에서 패닉이 터지는 형태로 나타납니다. 특히 CPU 바운드 연산, 파일 I/O, 동기 락 대기, 외부 프로세스 실행 등을 워커 스레드에서 돌리면 런타임이 “멈춘 것처럼” 보입니다.

재현 코드(나쁜 예)

#[tokio::main]
async fn main() {
    // 워커 스레드를 장시간 점유
    std::thread::sleep(std::time::Duration::from_secs(2));

    // 이 뒤의 async 작업들이 밀리면서 연쇄 장애
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}

해결책

  • 블로킹은 tokio::task::spawn_blocking 으로 격리합니다.
  • CPU 바운드가 많다면 별도 스레드풀(예: rayon) 또는 워커 프로세스로 분리합니다.
#[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}");
}

운영에서 “블로킹으로 인한 고갈”은 DB 커넥션 풀 고갈과 매우 비슷한 양상으로 나타납니다. 리소스가 대기열에 쌓이면 결국 타임아웃과 패닉이 터집니다. 커넥션 풀 관점의 진단 프레임은 이 글도 참고할 만합니다: Spring Boot HikariCP 커넥션 고갈 원인과 해결

5) block_on 사용 실수: 현재 스레드 런타임과의 상호작용 문제

block_on 자체는 나쁜 도구가 아니지만, 사용 위치/스레드에 따라 의도치 않은 상호작용을 만들 수 있습니다.

전형적인 증상

  • 특정 경로에서만 데드락/패닉
  • current_thread 런타임에서, 내부적으로 같은 스레드에 의존하는 작업을 기다리며 멈춤

재현 코드(개념 예시)

use tokio::runtime::Builder;

fn main() {
    let rt = Builder::new_current_thread().enable_all().build().unwrap();

    rt.block_on(async {
        // current_thread 런타임은 한 스레드에서만 폴링
        // 여기서 블로킹/락/대기 잘못 섞이면 멈추기 쉽다
        tokio::task::yield_now().await;
    });
}

해결책

  • 서버/고동시성 워크로드는 기본적으로 멀티스레드 런타임을 권장합니다.
  • block_on 은 “최상단 1회” 또는 “동기 진입점에서 짧게 async를 감싸는 용도”로 제한합니다.
  • 동기 코드에서 async를 호출해야 한다면, 장기적으로는 호출 체인을 async로 전환하는 것이 안전합니다.

6) Drop 시점에 런타임 의존 작업 수행: 런타임 종료 이후 사용

Drop 구현에서 tokio::spawn 을 하거나, async 정리 작업을 억지로 수행하려다 런타임이 이미 내려간 상태면 패닉/에러가 발생할 수 있습니다. 특히 전역(static) 객체, lazy 초기화 싱글톤, 로깅/메트릭 플러셔 등이 흔한 원인입니다.

전형적인 증상

  • 프로그램 종료 시점에만 터짐
  • 테스트 종료 시점에만 간헐적으로 터짐

재현 코드(나쁜 예)

struct Bad;

impl Drop for Bad {
    fn drop(&mut self) {
        // 드롭 시점에 런타임이 없을 수 있음
        tokio::spawn(async {
            // cleanup
        });
    }
}

#[tokio::main]
async fn main() {
    let _b = Bad;
}

해결책

  • 정리 작업은 Drop 이 아니라 명시적 shutdown() 메서드로 수행하고, 호출자가 await 하게 만듭니다.
  • 종료 훅이 필요하면 tokio::signal 을 이용해 종료 시퀀스를 명확히 구성합니다.
struct Good;

impl Good {
    async fn shutdown(self) {
        // async cleanup
    }
}

#[tokio::main]
async fn main() {
    let g = Good;
    // ...
    g.shutdown().await;
}

7) 스레드/메모리 리소스 한계: 스레드 생성 실패, OOM, 너무 큰 스택

Tokio 런타임은 워커 스레드, 블로킹 스레드풀, 네트워크 버퍼 등 다양한 리소스를 씁니다. 컨테이너에서 메모리 제한이 빡빡하거나, 시스템 스레드 제한이 낮거나, 무한 태스크 생성으로 메모리를 잡아먹으면 “런타임 패닉처럼 보이는 종료”가 발생합니다.

전형적인 증상

  • 컨테이너가 OOMKilled 또는 Exit 137
  • failed to spawn thread 류 메시지
  • 부하가 증가할수록 재현

재현 코드(태스크 폭증)

#[tokio::main]
async fn main() {
    loop {
        tokio::spawn(async {
            // 종료되지 않는 태스크가 계속 쌓임
            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
        });
    }
}

해결책

  • 태스크 생성에 상한을 둡니다. Semaphore 로 동시성 제한을 걸어 폭증을 막습니다.
  • 큐 기반 워커 패턴을 사용해 생산자/소비자 속도를 제어합니다.
  • 런타임 설정을 워크로드에 맞게 조정합니다. 예를 들어 워커 스레드 수를 명시하거나, 블로킹 스레드풀 사용을 점검합니다.

동시성 제한 예시:

use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
    let sem = Arc::new(Semaphore::new(100));

    for _i in 0..10_000 {
        let permit = sem.clone().acquire_owned().await.unwrap();
        tokio::spawn(async move {
            let _permit = permit; // 작업 중 permit 유지
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        });
    }
}

컨테이너/쿠버네티스에서 “로그 없이 죽는” 느낌이라면, 앱 레벨 패닉 이전에 OOM이나 강제 종료일 가능성도 큽니다. 운영 진단 관점에서는 이 글의 접근이 유용합니다: EKS Pod CrashLoopBackOff 로그 없을 때 7단계 진단

패닉을 빨리 좁히는 실전 체크리스트

1) 패닉 메시지를 분류

  • Cannot start a runtime from within a runtime 이면 런타임 중첩
  • there is no reactor running 이면 런타임 컨텍스트 누락
  • task panicked 또는 JoinError 이면 태스크 내부 패닉 전파

2) unwrap() 의 위치를 찾기

  • JoinHandle.await.unwrap()
  • Result 를 반환하는 I/O 호출의 unwrap()
  • 채널 송수신(send().await.unwrap())의 수신자 종료

가능하면 다음처럼 컨텍스트를 붙여 에러를 남깁니다.

use anyhow::Context;

async fn run() -> anyhow::Result<()> {
    let _ = tokio::fs::read("missing.txt")
        .await
        .context("failed to read config file")?;
    Ok(())
}

3) 블로킹 호출이 섞였는지 점검

  • std::thread::sleep
  • 동기 DB 클라이언트
  • 무거운 CPU 연산
  • mutex를 오래 잡는 코드

발견되면 spawn_blocking 또는 아키텍처 분리로 격리합니다.

4) 종료 시퀀스를 명시적으로 설계

  • Drop 에서 async 정리 금지
  • shutdown().await 를 제공
  • tokio::signal 로 종료 이벤트를 받아 정리 후 종료

마무리

Tokio 런타임 패닉은 대부분 “런타임을 어디서 만들었는지”, “런타임 컨텍스트가 보장되는지”, “태스크 경계에서 패닉을 어떻게 다루는지”, “블로킹/리소스 폭증이 있는지” 네 축으로 정리됩니다. 위 7가지는 실무에서 재현 빈도가 높은 패턴이므로, 에러 메시지와 코드 구조를 대조해보면 원인을 빠르게 좁힐 수 있습니다.

원하시면 실제 패닉 로그(메시지 전문)와 런타임 생성 코드, Cargo.tomltokio feature 설정을 알려주시면, 어떤 케이스에 가장 가까운지 기준을 잡아 구체적으로 디버깅 플랜까지 제안해드릴게요.