Published on

Rust Tokio에서 thread panicked 원인 추적법

Authors

서버를 Rust tokio로 운영하다 보면 어느 날 갑자기 로그에 thread panicked가 찍히고, 요청이 끊기거나 프로세스가 종료되거나(혹은 일부 태스크만 죽고 계속 동작하거나) 하는 일이 생깁니다. 문제는 이 메시지 자체가 매우 포괄적이라서, 단순히 “패닉이 났다” 이상의 정보를 주지 않는다는 점입니다.

이 글에서는 Tokio 환경에서 패닉의 실제 원인을 빠르게 좁히는 방법을 재현 가능성 확보부터 태스크 단위 격리, 백트레이스/훅/계측까지 단계적으로 정리합니다.

또한 운영 환경에서 “가끔만 터지는 패닉”을 다룰 때 필요한 로그 전략도 함께 다룹니다. 비슷한 방식으로 운영 장애 원인을 좁히는 사고방식은 다른 스택에도 그대로 적용되는데, 예를 들어 systemd 서비스가 반복 재시작될 때 원인 추적 글의 접근과도 상당히 유사합니다.

1) 먼저 확인: 이 패닉이 어디서 났는가

Tokio에서 thread panicked가 보일 때, 크게 세 갈래로 나뉩니다.

  1. 메인 스레드(또는 런타임 스레드)가 패닉
    • 프로세스 자체가 종료될 수 있습니다.
  2. spawn된 태스크 내부에서 패닉
    • 태스크만 죽고 런타임은 계속 돌 수 있습니다.
    • 다만 JoinHandleawait하는 쪽에서 JoinError로 표면화됩니다.
  3. 블로킹 스레드 풀(예: spawn_blocking)에서 패닉
    • CPU 작업이나 동기 라이브러리 호출에서 패닉이 터질 수 있습니다.

따라서 첫 질문은 “이 패닉이 프로세스를 죽였는지, 아니면 특정 태스크만 죽였는지”입니다.

  • 프로세스가 죽었다면 panic = abort 설정 여부, 혹은 메인 태스크의 패닉 여부를 의심해야 합니다.
  • 프로세스가 살아있다면 대부분 tokio::spawn 내부 패닉이며, JoinError를 제대로 관찰하고 있는지부터 점검해야 합니다.

2) 백트레이스 확보: 운영에서도 바로 효과 보는 1순위

패닉 원인 추적의 첫 단추는 백트레이스입니다.

2-1) 환경변수로 백트레이스 켜기

실행 환경에서 다음을 설정합니다.

  • RUST_BACKTRACE=1
  • 더 자세히 보고 싶으면 RUST_BACKTRACE=full

Kubernetes나 systemd 환경이라면 서비스 유닛이나 Deployment env로 넣어두는 것이 좋습니다.

2-2) 릴리즈 빌드에서도 라인 정보를 남기기

릴리즈에서 라인 정보가 사라지면 백트레이스가 쓸모가 크게 줄어듭니다. 다음 중 하나를 권장합니다.

  • 운영 바이너리도 디버그 심볼을 포함(용량 증가)
  • 별도 심볼 파일을 보관하고 크래시 덤프와 매칭

Cargo.toml 예시입니다.

[profile.release]
debug = 1

debug = 1은 용량과 정보량 사이 타협점으로 자주 사용합니다.

3) Tokio 태스크 패닉은 JoinError로 잡아라

Tokio에서 가장 흔한 실수는 spawn한 태스크의 실패를 “그냥 버리는 것”입니다. JoinHandle을 무시하면 태스크 내부 패닉이 조용히 지나가고, 로그에는 thread panicked만 남아 실제 원인(어떤 요청 컨텍스트에서 어떤 입력으로 터졌는지)이 사라집니다.

3-1) 나쁜 예: 핸들을 버림

use tokio::task;

async fn background_job() {
    // 어떤 조건에서 패닉
    panic!("boom");
}

#[tokio::main]
async fn main() {
    task::spawn(background_job());

    // 메인은 계속 진행
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}

3-2) 좋은 예: JoinError를 반드시 로깅

use tokio::task;

async fn background_job() {
    panic!("boom");
}

#[tokio::main]
async fn main() {
    let handle = task::spawn(background_job());

    match handle.await {
        Ok(_) => {}
        Err(e) => {
            if e.is_panic() {
                eprintln!("task panicked: {e}");
            } else {
                eprintln!("task cancelled or failed: {e}");
            }
        }
    }
}

핵심은 JoinError를 통해 “태스크 패닉인지”, “취소인지”를 구분하고, 패닉이면 추가 컨텍스트를 함께 남길 수 있는 구조로 바꾸는 것입니다.

4) 패닉 훅으로 로그 품질을 올려라

운영에서 백트레이스만으로 부족한 경우가 많습니다. 특히 “어떤 요청”에서 터졌는지를 알아야 재현이 쉬워집니다.

Rust는 std::panic::set_hook으로 패닉 발생 시점에 로그를 남길 수 있습니다. tracing을 쓰는 환경이라면 훅에서 tracing::error!로 뿌리면 수집 파이프라인에 그대로 실립니다.

use std::panic;

fn install_panic_hook() {
    panic::set_hook(Box::new(|info| {
        // info 안에 메시지, 위치 등이 들어있음
        eprintln!("panic captured: {info}");

        // 필요하다면 백트레이스를 강제로 출력
        // (RUST_BACKTRACE 설정 여부에 따라 달라질 수 있음)
        let bt = std::backtrace::Backtrace::force_capture();
        eprintln!("backtrace: {bt}");
    }));
}

#[tokio::main]
async fn main() {
    install_panic_hook();

    // ...
}

이 훅은 “왜 thread panicked가 찍혔는지”를 로그에 더 풍부하게 남기는 역할을 합니다.

주의: 훅에서 무거운 작업은 피하기

패닉 훅은 비정상 상황에서 실행됩니다. 네트워크 전송 같은 무거운 로직을 넣으면 2차 장애나 데드락 위험이 있습니다. 가능한 한 표준 에러 출력 또는 로컬 로깅으로 제한하세요.

5) 패닉을 유발하는 대표 원인 패턴과 체크리스트

Tokio에서 자주 마주치는 패닉 원인을 “증상 기반”으로 정리합니다.

5-1) unwrap()expect()

비동기 코드에서 unwrap()은 특히 위험합니다. 네트워크 오류, 타임아웃, 채널 종료 등 “정상적인 실패”가 많기 때문입니다.

  • Result는 상위로 전파하고
  • 경계 지점(HTTP 핸들러, 워커 루프)에서 로깅과 함께 처리
async fn handle() -> anyhow::Result<()> {
    let body = fetch().await?; // unwrap 대신 ?
    process(body).await?;
    Ok(())
}

5-2) tokio::sync 채널 사용 실수

  • mpsc::Sender::send는 수신자가 모두 drop되면 실패합니다.
  • oneshot은 상대가 먼저 종료되면 실패합니다.

이 실패를 unwrap()하면 간헐적으로 패닉이 납니다.

use tokio::sync::mpsc;

async fn producer(tx: mpsc::Sender<i32>) {
    if let Err(e) = tx.send(1).await {
        // 수신자 종료는 운영에서 흔함: 정상 경로로 처리
        eprintln!("send failed: {e}");
    }
}

5-3) spawn_blocking 내부 패닉

동기 라이브러리 호출, CPU 바운드 연산을 spawn_blocking으로 넘기는 경우가 많습니다. 여기서도 unwrap()은 그대로 패닉으로 이어집니다.

let handle = tokio::task::spawn_blocking(|| {
    // 동기 코드
    panic!("blocking panic");
});

let res = handle.await;
println!("join result: {res:?}");

spawn_blockingJoinError로 관찰 가능하므로, 반드시 await하고 오류를 로깅하세요.

5-4) 런타임 내부에서 block_on 중첩

Tokio 런타임 안에서 또 다른 런타임을 만들거나 block_on을 잘못 호출하면 패닉이 발생할 수 있습니다(대표적으로 “런타임 안에서 블로킹 실행” 류의 메시지).

해결 방향은 보통 다음 중 하나입니다.

  • 라이브러리 API를 async 버전으로 교체
  • 불가피하면 spawn_blocking으로 격리
  • 런타임 생성 위치를 최상단으로 단일화

6) tracing으로 “어떤 입력에서 터졌는지”를 남기는 법

백트레이스가 있어도 “왜 그 코드가 그 입력을 받았는지”가 안 보이면 재현이 어렵습니다. 이때 tracing의 스팬이 매우 유용합니다.

6-1) 요청 단위 스팬과 태스크 연결

use tracing::{info_span, Instrument};

async fn do_work(user_id: String) {
    // ...
    panic!("bad state");
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let user_id = "u-123".to_string();
    let span = info_span!("request", user_id = %user_id);

    let handle = tokio::spawn(do_work(user_id).instrument(span));

    if let Err(e) = handle.await {
        eprintln!("task failed: {e}");
    }
}

이렇게 하면 패닉 로그가 찍힐 때 스팬 필드가 함께 남아 “어떤 사용자, 어떤 요청”인지가 즉시 보입니다.

6-2) 간헐 장애는 “재현 로그”가 생명

간헐적으로만 터지는 패닉은 보통 경쟁 조건, 타이밍, 특정 데이터 조합에서 발생합니다.

  • 입력(요청 바디, 주요 파라미터)을 그대로 남기기 어렵다면 해시나 샘플링을 고려
  • 실패 케이스에서만 상세 로그를 남기도록 설계

이런 접근은 DB 데드락을 재현하기 위해 로그를 구조화하는 방식과도 닮아 있습니다. 참고로 비슷한 문제 해결 흐름은 MySQL Deadlock 1213 로그로 원인 찾고 재현하기에서도 확인할 수 있습니다.

7) 패닉을 “죽지 않게” 만들 것인가, “빨리 죽게” 만들 것인가

운영 정책에 따라 선택이 갈립니다.

  • 일부 태스크의 패닉은 격리하고 서비스는 계속 제공하고 싶다
  • 반대로, 패닉이 나면 프로세스를 즉시 죽여 오케스트레이터가 재시작하게 하고 싶다

7-1) panic = abort 옵션

panic = abort는 패닉 시 스택 언와인딩 없이 즉시 종료합니다.

[profile.release]
panic = "abort"

장점: 상태가 꼬인 채로 계속 서비스하는 위험을 줄임 단점: 백트레이스/정리 로직이 제한될 수 있음

7-2) 태스크 경계에서 패닉을 실패로 바꾸기

Rust는 일반적으로 패닉을 복구하는 패턴을 권장하진 않지만, “워커 루프” 같은 곳에서는 격리 목적의 복구가 실용적일 때가 있습니다. 다만 async 경계에서의 catch_unwind는 제약이 있으므로, 보통은 블로킹 작업을 경계로 감싸는 식으로 제한적으로 사용합니다.

핵심은 “패닉을 숨기기”가 아니라 “패닉을 관측 가능하게 만들고 피해 범위를 제한”하는 것입니다.

8) 실전 디버깅 절차: 재현 불가한 thread panicked를 좁히는 순서

운영에서 바로 적용 가능한 순서로 정리합니다.

  1. RUST_BACKTRACE=1 또는 full 활성화
  2. 릴리즈 빌드에 라인 정보를 포함하도록 프로파일 조정
  3. std::panic::set_hook로 패닉 로그 표준화
  4. tokio::spawnJoinHandle을 버리지 말고 반드시 await하거나, 중앙에서 수거하는 구조로 변경
  5. JoinErroris_panic 기준으로 분기 로깅
  6. tracing 스팬으로 요청 컨텍스트를 태스크에 주입
  7. unwrap/expect 제거: 특히 채널, IO, 타임아웃 경계
  8. 블로킹 작업은 spawn_blocking으로 격리하고, 그 결과도 관측
  9. 그래도 재현이 안 되면 “실패 케이스에서만” 입력을 더 자세히 남기는 로깅을 추가

이 과정을 거치면 thread panicked가 더 이상 “정체불명의 메시지”가 아니라, 특정 태스크와 특정 입력, 특정 코드 라인으로 수렴하는 형태로 바뀝니다.

9) 마무리: 패닉은 없애기보다, 빨리 찾게 만들어라

Tokio 기반 서비스에서 패닉을 완전히 0으로 만드는 것은 현실적으로 어렵습니다. 중요한 건 패닉이 났을 때

  • 어디서 났는지
  • 어떤 입력에서 났는지
  • 프로세스를 죽였는지 태스크만 죽였는지

를 빠르게 파악할 수 있는 관측 가능성입니다.

백트레이스, 패닉 훅, JoinError 처리, tracing 스팬을 조합하면 thread panicked는 “추적 불가능한 운영 공포”에서 “재현 가능한 버그”로 바뀝니다.