Published on

Rust tokio - Cannot start a runtime 원인 7가지

Authors

서버나 배치 작업을 Rust로 옮길 때 Tokio를 붙이면 생산성이 확 올라가지만, 어느 순간 Cannot start a runtime 같은 메시지와 함께 갑자기 패닉이 터지는 경우가 있습니다. 이 문구는 하나의 고정된 컴파일 에러가 아니라, **"런타임을 시작할 수 없는 상황"**에서 Tokio가 내뱉는 여러 패닉/에러의 대표적인 표현으로 이해하는 편이 좋습니다.

이 글에서는 현장에서 재현이 잦은 패턴을 7가지로 묶어, **왜 그런지(원인)**와 **어떻게 고치는지(해결)**를 코드 중심으로 정리합니다.

운영 환경에서 이 문제가 크래시로 이어지면, Kubernetes에서는 바로 CrashLoopBackOff로 번질 수 있습니다. 런타임 패닉을 빠르게 추적하는 절차는 K8s CrashLoopBackOff 원인 10분 추적법도 함께 참고하면 좋습니다.

1) #[tokio::main] 안에서 또 런타임을 만든 경우(중첩 런타임)

가장 흔합니다. 이미 #[tokio::main]으로 런타임이 떠 있는데, 내부에서 다시 tokio::runtime::Runtime::new() 또는 Builder::new_multi_thread().build()를 호출하면 런타임 중첩 문제가 발생합니다.

재현 코드

use tokio::runtime::Runtime;

#[tokio::main]
async fn main() {
    // 이미 Tokio 런타임이 실행 중인 상태
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        println!("nested runtime");
    });
}

해결 패턴

  • 가능하면 런타임은 프로세스당 1개만 만들고, 나머지는 async fn으로 전파합니다.
  • 테스트나 라이브러리 코드에서 런타임이 필요하면, 호출자에게 Handle을 받거나 async API로 설계합니다.
#[tokio::main]
async fn main() {
    do_work().await;
}

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

2) 런타임 내부에서 block_on을 호출한 경우

block_on은 "현재 스레드를 막고, 해당 future가 끝날 때까지 기다리는" API입니다. 문제는 이미 런타임이 그 스레드에서 스케줄링 중일 때 block_on을 다시 호출하면 교착/패닉 가능성이 커진다는 점입니다.

특히 다음 조합이 위험합니다.

  • #[tokio::main] 내부에서 Handle::current().block_on(...)
  • async 컨텍스트에서 동기 함수가 block_on을 호출

잘못된 예

use tokio::runtime::Handle;

#[tokio::main]
async fn main() {
    let h = Handle::current();
    h.block_on(async {
        println!("this can panic or deadlock");
    });
}

해결

  • async에서는 그냥 await 합니다.
  • 동기 함수에서 async를 호출해야 한다면, 경계를 분리합니다(예: 상위에서만 block_on).
#[tokio::main]
async fn main() {
    println!("await instead of block_on");
}

3) tokio::spawn을 런타임 밖에서 호출한 경우

tokio::spawn은 현재 실행 중인 런타임(정확히는 스케줄러 컨텍스트)을 필요로 합니다. 런타임이 없는 스레드나, 런타임이 종료된 뒤에 호출하면 실패합니다. 상황에 따라 에러 메시지가 there is no reactor running 등으로 나타나지만, 런타임 시작 실패/부재 계열로 묶입니다.

재현 코드

fn main() {
    tokio::spawn(async {
        println!("no runtime");
    });
}

해결

  • #[tokio::main] 또는 직접 만든 Runtime 내부에서만 spawn합니다.
  • 라이브러리라면 spawn 대신 async fn을 노출하고, 호출자가 스폰하도록 설계합니다.
#[tokio::main]
async fn main() {
    tokio::spawn(async {
        println!("spawn inside runtime");
    })
    .await
    .unwrap();
}

4) std::sync::Mutex 같은 블로킹 락으로 런타임 스레드를 막은 경우

Tokio는 협력적 스케줄링을 전제로 합니다. 런타임 워커 스레드에서 오래 걸리는 블로킹 작업(락 대기 포함)이 발생하면, 다른 task 진행이 멈추고 이상 징후가 연쇄적으로 나타납니다.

이 자체가 곧바로 Cannot start a runtime를 만들지는 않더라도, **"런타임이 정상적으로 돌지 못하는 상태"**를 만들고, 재시작 로직/서브 런타임 생성과 결합될 때 문제를 폭발시키는 경우가 많습니다.

흔한 실수

use std::sync::{Arc, Mutex};

#[tokio::main]
async fn main() {
    let m = Arc::new(Mutex::new(0));

    let m2 = m.clone();
    tokio::spawn(async move {
        let _g = m2.lock().unwrap();
        // 여기서 오래 걸리는 작업을 하면 런타임 워커 스레드가 막힘
        std::thread::sleep(std::time::Duration::from_secs(3));
    })
    .await
    .unwrap();
}

해결

  • async 코드에서는 tokio::sync::Mutex를 고려합니다.
  • 어쩔 수 없는 블로킹 작업은 tokio::task::spawn_blocking으로 격리합니다.
use std::sync::{Arc, Mutex};

#[tokio::main]
async fn main() {
    let m = Arc::new(Mutex::new(0));

    let m2 = m.clone();
    tokio::task::spawn_blocking(move || {
        let _g = m2.lock().unwrap();
        std::thread::sleep(std::time::Duration::from_secs(1));
    })
    .await
    .unwrap();
}

5) Runtime를 여러 번 만들고 드롭하는 구조(라이브러리/헬퍼에서 런타임 생성)

다음과 같은 유틸 함수는 "편해 보이지만" 장애를 부릅니다.

  • 호출할 때마다 런타임을 새로 생성
  • 내부에서 block_on으로 실행 후 런타임 드롭
  • 호출자가 이미 런타임을 가진 경우 중첩/충돌

문제 패턴

use tokio::runtime::Runtime;

pub fn run_sync<F: std::future::Future<Output = ()>>(f: F) {
    let rt = Runtime::new().unwrap();
    rt.block_on(f);
}

이 함수가 async 컨텍스트에서 호출되면 1번(중첩 런타임)과 결합됩니다.

해결

  • 라이브러리는 런타임 생성 책임을 호출자에게 넘기고, async fn 위주로 API를 제공합니다.
  • 정말 동기 API가 필요하면, Handle을 인자로 받는 형태로 타협합니다.
use tokio::runtime::Handle;

pub fn run_on_handle<F: std::future::Future<Output = ()>>(h: &Handle, f: F) {
    h.spawn(f);
}

6) 멀티스레드 런타임을 만들 수 없는 실행 환경(스레드 생성 제한)

컨테이너/서버리스/샌드박스 환경에서 스레드 생성이 제한되면, Tokio 멀티스레드 런타임 생성 자체가 실패할 수 있습니다.

  • 보안 프로파일(예: seccomp)로 clone 계열 제한
  • 극단적으로 작은 ulimit 또는 스레드 수 제한
  • 리소스 고갈로 스레드 생성 실패

이 경우 런타임 생성 시점에서 unwrap()로 패닉이 터지며, 메시지가 런타임 시작 실패로 보일 수 있습니다.

재현에 가까운 코드(실패 시 패닉)

use tokio::runtime::Builder;

fn main() {
    let rt = Builder::new_multi_thread()
        .worker_threads(4)
        .enable_all()
        .build()
        .unwrap();

    rt.block_on(async {
        println!("hello");
    });
}

해결 체크리스트

  • 멀티스레드가 꼭 필요 없으면 new_current_thread()로 전환
  • 쿠버네티스라면 보안 정책/런타임 제한을 확인하고, 크래시 루프라면 원인 추적을 빠르게 진행
use tokio::runtime::Builder;

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

    rt.block_on(async {
        println!("single thread runtime");
    });
}

운영에서 이런 문제는 "애플리케이션 버그"처럼 보이지만, 사실은 노드/컨테이너 상태 문제일 때도 많습니다. 크래시 루프가 반복되면 Kubernetes CrashLoopBackOff 12가지 원인·해결처럼 인프라 관점도 함께 점검하는 게 좋습니다.

7) tokio::test와 전역 상태/싱글턴이 충돌한 경우(테스트 런타임 난립)

테스트에서 #[tokio::test]를 여러 개 돌리면, 테스트 러너가 병렬로 실행하면서 런타임이 여러 개 생기고, 동시에 전역 상태(예: 로거 초기화, 전역 싱글턴, 포트 바인딩)를 공유하려다 실패합니다.

이때 표면적으로는 "런타임이 시작되지 않는다"처럼 보이지만, 실제 원인은 전역 리소스 충돌인 경우가 많습니다.

흔한 증상

  • 로거 초기화가 set_logger에서 한 번만 가능해서 패닉
  • 동일 포트 바인딩 경쟁
  • 전역 OnceCell에 런타임/핸들을 넣어두고 중복 초기화

해결

  • 테스트를 직렬화하거나, 전역 리소스를 테스트마다 분리
  • 로거는 try_init 계열 사용
  • 포트는 0으로 바인딩해서 OS가 할당하도록 하고 실제 포트를 조회
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_one() {
    // 전역 초기화는 try 방식으로
    let _ = tracing_subscriber::fmt::try_init();
    assert_eq!(1, 1);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_two() {
    let _ = tracing_subscriber::fmt::try_init();
    assert_eq!(2, 2);
}

진단 팁: 메시지보다 "어디서 런타임을 만들었는지"를 먼저 본다

Cannot start a runtime 계열은 메시지만 보면 막막합니다. 대신 아래 순서로 보면 빨리 좁혀집니다.

  1. 런타임 생성 지점을 전부 찾기: #[tokio::main], #[tokio::test], Runtime::new, Builder::build
  2. 런타임 내부에서 block_on 호출이 있는지 검색
  3. 런타임 밖에서 tokio::spawn 호출이 있는지 검색
  4. 블로킹 호출이 런타임 워커 스레드에서 실행되는지 확인: std::thread::sleep, 동기 I/O, std::sync::Mutex 장기 점유
  5. 배포 환경에서 스레드 생성 제한/리소스 고갈 가능성 확인

크래시가 컨테이너에서 반복되면, 애플리케이션 스택트레이스와 함께 이벤트/로그를 묶어서 확인해야 합니다. 이때 문제 분류 자체는 Rust/Tokio지만, 운영 증상은 쿠버네티스의 재시작 패턴으로 나타납니다. 빠른 트리아지는 K8s CrashLoopBackOff 원인 10분 추적법이 실전에서 도움이 됩니다.

정리

  • 런타임 관련 패닉의 대부분은 중첩 런타임, 잘못된 block_on, **런타임 외부 spawn**에서 시작합니다.
  • 그 다음으로는 블로킹 작업 격리 실패, 런타임 생성 책임이 뒤섞인 설계, 스레드 생성 제한 같은 환경 요인, 테스트 병렬 실행과 전역 상태 충돌이 자주 원인이 됩니다.
  • 해결의 핵심은 "런타임은 상위에서 한 번만" 그리고 "async는 async로 끝까지"입니다.