Published on

Rust Tokio task 누수로 RAM 폭증 진단법

Authors

운영 중인 Rust Tokio 서비스에서 RAM이 서서히 혹은 계단식으로 폭증하는데, CPU는 비교적 한가하고 GC도 없는 Rust라서 원인이 감이 안 잡히는 경우가 있습니다. 이런 패턴은 종종 task 누수(task leak)—즉, 종료되어야 할 비동기 작업이 종료되지 못하고 계속 살아남는 상황—에서 발생합니다.

Tokio task가 누수되면 단순히 task 개수만 늘어나는 게 아니라, task가 붙잡고 있는 캡처된 클로저/Arc/버퍼/채널/소켓/타이머가 함께 남아 메모리와 FD가 같이 증가합니다. 이 글은 “메모리가 늘어난다”에서 끝나지 않고, 어떤 task가 왜 안 끝나는지를 좁혀가는 진단 루틴을 제공합니다.

진단 관점에서 이 문제는 리눅스 레벨 OOM/컨테이너 메모리 제한과도 연결됩니다. OOM이 실제로 발생했다면 먼저 커널 로그와 cgroup 관점의 근거를 확보하는 게 좋습니다: Linux OOM Killer 원인추적 - dmesg·cgroup·로그

1) task 누수의 전형적인 증상 체크리스트

다음 징후가 2개 이상이면 Tokio task 누수를 강하게 의심할 수 있습니다.

  • 프로세스 RSS가 계속 증가하고, 트래픽이 줄어도 잘 내려오지 않는다
  • tokio::spawn 호출 빈도는 높지만, 작업 완료 로그/메트릭이 비대칭이다
  • JoinHandle을 버리거나(Detached) 취소/종료 경로가 없다
  • select!에서 한 브랜치가 영원히 pending이라 종료 조건이 없다
  • mpsc/broadcast 수신 루프가 종료되지 않거나, sender/receiver가 서로를 잡고 있다
  • 타임아웃이 없다(네트워크, 락, 채널 recv, sleep, backoff 등)
  • Arc 순환 참조 또는 DashMap/캐시가 task 종료와 무관하게 커진다

2) 가장 먼저 할 일: “task 수”와 “메모리”를 같은 그래프에 올리기

메모리만 보면 원인이 너무 넓습니다. **task 수(또는 task 생성률)**를 함께 봐야 원인 범위를 급격히 줄일 수 있습니다.

2.1 Tokio Metrics로 task 계측하기

Tokio는 공식적으로 tokio-metrics 크레이트를 제공합니다. 런타임 전체의 task/폴링/스케줄링 통계를 주기적으로 수집할 수 있습니다.

// Cargo.toml
// tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
// tokio-metrics = "0.3"

use tokio_metrics::RuntimeMonitor;
use std::time::Duration;

#[tokio::main(flavor = "multi_thread")]
async fn main() {
    let handle = tokio::runtime::Handle::current();
    let monitor = RuntimeMonitor::new(&handle);

    tokio::spawn(async move {
        let mut interval = tokio::time::interval(Duration::from_secs(5));
        loop {
            interval.tick().await;
            let m = monitor.cumulative();
            // 핵심: total_tasks_count, active_tasks_count 같은 지표를 로그/메트릭으로
            eprintln!(
                "tasks_total={} tasks_active={} park_count={} busy_duration={:?}",
                m.total_tasks_count,
                m.active_tasks_count,
                m.total_park_count,
                m.total_busy_duration
            );
        }
    });

    // 서버 로직...
    loop {
        tokio::time::sleep(Duration::from_secs(60)).await;
    }
}

여기서 중요한 건 “절대값”보다 추세입니다.

  • tasks_total은 계속 증가하는데, tasks_active가 줄지 않는다: 장기 생존 task가 늘어나는 패턴
  • tasks_total 증가율이 트래픽과 비례하지 않는다: 특정 루프/재시도 로직이 폭주
  • 메모리 증가와 tasks_total 증가가 동조한다: task 누수 가능성 매우 큼

2.2 OS 레벨에서도 동시에 확인하기

  • ps -o pid,rss,vsz,etimes,cmd -p $PID
  • pmap -x $PID | tail -n 20 (맵 증가 패턴)
  • 컨테이너라면 cgroup 메모리 사용량

메모리 폭증이 실제로 OOM으로 이어진다면, 앞서 링크한 OOM 진단 글처럼 커널이 누구를 왜 죽였는지를 먼저 고정 증거로 확보하세요.

3) “어떤 task가 안 끝나는가”를 찾는 방법

Tokio task는 스레드처럼 gdb로 스택을 한 번에 보기 어렵습니다. 대신 다음 3가지 축으로 좁힙니다.

  1. task에 이름/ID/라벨을 붙여 로그 상관관계를 만든다
  2. tracing으로 생명주기(생성~종료) 이벤트를 남긴다
  3. JoinHandle을 추적해 취소/완료 여부를 강제한다

3.1 task 라벨링: 이름 없는 spawn을 없애기

tokio::spawn(async move { ... })를 마구 쓰면 나중에 원인을 추적하기 어렵습니다. 최소한 task 시작/종료를 구조적으로 남기세요.

use tracing::{info, Instrument};
use std::sync::atomic::{AtomicU64, Ordering};

static TASK_SEQ: AtomicU64 = AtomicU64::new(1);

fn next_task_id() -> u64 {
    TASK_SEQ.fetch_add(1, Ordering::Relaxed)
}

async fn spawn_labeled<F>(name: &'static str, fut: F) -> tokio::task::JoinHandle<()>
where
    F: std::future::Future<Output = ()> + Send + 'static,
{
    let id = next_task_id();
    tokio::spawn(
        async move {
            info!(task.id = id, task.name = name, "task_start");
            fut.await;
            info!(task.id = id, task.name = name, "task_end");
        }
        .instrument(tracing::info_span!("task", task.id = id, task.name = name)),
    )
}

이렇게만 해도 “시작은 많은데 끝이 없다”를 로그에서 바로 발견할 수 있습니다.

3.2 JoinHandle을 버리지 말고, 관리 테이블을 둔다

Detached task는 누수의 시작점이 됩니다. 백그라운드 task라도 수명 관리가 필요합니다.

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

struct TaskGroup {
    cancel: CancellationToken,
    handles: Vec<JoinHandle<()>>,
}

impl TaskGroup {
    fn new() -> Self {
        Self { cancel: CancellationToken::new(), handles: Vec::new() }
    }

    fn token(&self) -> CancellationToken {
        self.cancel.clone()
    }

    fn spawn(&mut self, h: JoinHandle<()>) {
        self.handles.push(h);
    }

    async fn shutdown(mut self) {
        self.cancel.cancel();
        for h in self.handles.drain(..) {
            let _ = h.await;
        }
    }
}

운영에서 “종료가 보장되지 않는 task”는 결국 메모리/FD/락을 끌고 가는 경우가 많습니다.

4) 원인 유형별 진단 포인트 (가장 자주 터지는 6가지)

4.1 select!에서 종료 조건이 없는 무한 대기

다음 패턴은 매우 흔합니다. 한 브랜치가 영원히 pending이면 종료가 안 됩니다.

use tokio::select;

async fn worker(mut rx: tokio::sync::mpsc::Receiver<Vec<u8>>) {
    loop {
        select! {
            msg = rx.recv() => {
                // rx가 닫히면 msg는 None이 되지만, 이를 처리하지 않으면 루프가 계속 돈다
                if let Some(_m) = msg {
                    // 처리
                }
            }
            _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
                // 주기 작업
            }
        }
    }
}

수정 포인트는 “종료 신호”를 명확히 다루는 것입니다.

async fn worker(
    mut rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
    cancel: tokio_util::sync::CancellationToken,
) {
    loop {
        tokio::select! {
            _ = cancel.cancelled() => {
                break;
            }
            msg = rx.recv() => {
                match msg {
                    Some(_m) => { /* 처리 */ }
                    None => break, // 채널 종료 시 worker 종료
                }
            }
        }
    }
}

4.2 타임아웃 없는 I/O 대기 (특히 외부 의존성)

네트워크/DB/Redis/HTTP 호출이 타임아웃 없이 대기하면 task는 계속 살아있습니다. 커넥션 풀과 결합하면 “대기 task 폭증”이 됩니다.

use tokio::time::{timeout, Duration};

async fn call_with_timeout() -> anyhow::Result<()> {
    let res = timeout(Duration::from_secs(2), async {
        // 외부 호출
        Ok::<_, anyhow::Error>(())
    }).await;

    match res {
        Ok(inner) => inner,
        Err(_) => anyhow::bail!("timeout"),
    }
}

Kubernetes/EKS 환경에서 외부 연결 이슈가 길게 지속되면 task가 대량 적체될 수 있습니다. 네트워크 타임아웃/재시도 정책도 함께 점검하세요.

4.3 mpsc 적체로 인한 메모리 증가 (누수처럼 보이는 케이스)

엄밀히는 누수가 아니라 백프레셔 부재로 큐가 커지는 상황입니다. 하지만 운영에서는 RAM이 계속 오르므로 누수처럼 보입니다.

  • mpsc::unbounded_channel 사용
  • bounded 채널인데도 생산자가 await를 회피(예: try_send 실패를 무시)
  • 소비자 task가 멈췄는데 생산자는 계속 push

해결은 대개 다음 중 하나입니다.

  • bounded 채널로 바꾸고, send().await로 백프레셔를 강제
  • 큐 길이를 메트릭으로 노출하고, 일정 길이 이상이면 드롭/샘플링
  • 소비자 장애 시 생산도 멈추게(서킷 브레이커)
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1024);

// 생산자
tokio::spawn(async move {
    loop {
        let payload = vec![0u8; 4096];
        if tx.send(payload).await.is_err() {
            break; // 소비자 종료 시 생산자도 종료
        }
    }
});

// 소비자
tokio::spawn(async move {
    while let Some(_msg) = rx.recv().await {
        // 처리
    }
});

4.4 Arc 순환 참조로 task가 간접적으로 살아남기

Rust는 참조 카운트 기반인 Arc에서 순환 참조가 생기면 해제가 안 됩니다. task가 Arc를 잡고 있고, 그 Arc가 다시 task를 잡는 구조면 task가 끝나도 메모리가 남습니다.

  • 콜백/리스너를 Arc 내부에 저장
  • Arc가 자기 자신을 캡처한 클로저를 필드로 가짐

진단:

  • 구조체 필드에 Arc가 여러 겹으로 얽혀 있는지 확인
  • 가능하면 콜백 저장은 Weak로 바꾸기
use std::sync::{Arc, Weak};

struct Hub {
    // 구독자 목록 등을 Weak로 보관
    subs: Vec<Weak<Subscriber>>,
}

struct Subscriber {
    // ...
}

4.5 JoinSet/재시도 루프가 끝없이 task를 만든다

에러 시 재시도를 하되, 이전 task를 정리하지 않거나 재시도 간격이 너무 짧으면 task 생성이 폭주합니다.

  • 재시도에 상한(최대 횟수, 최대 동시성)이 있는가
  • 실패 시에도 sleep/backoff가 있는가
  • 같은 키에 대해 중복 task가 생기지 않게 dedupe 하는가
use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};

async fn run_supervisor() {
    let mut set = JoinSet::new();

    for _ in 0..100 {
        set.spawn(async {
            // 작업
        });
    }

    while let Some(res) = set.join_next().await {
        if res.is_err() {
            sleep(Duration::from_millis(200)).await; // 최소 backoff
        }
    }
}

4.6 spawn_blocking 남용 또는 blocking 코드 혼입

blocking 코드가 async task 내부에 들어가면, 런타임이 굳고 소비자 task가 느려져 큐 적체가 생깁니다. 결과적으로 메모리 증가로 이어질 수 있습니다.

  • 파일 I/O, 압축, 대용량 JSON 파싱, 암호화 등을 async task에서 직접 수행
  • spawn_blocking을 쓰되 동시성을 제한하지 않음

해결:

  • blocking 작업을 spawn_blocking으로 보내고
  • Semaphore로 동시성 제한
use tokio::sync::Semaphore;
use std::sync::Arc;

async fn limited_blocking_work(sem: Arc<Semaphore>) {
    let _permit = sem.acquire().await.unwrap();
    let _ = tokio::task::spawn_blocking(move || {
        // CPU/IO heavy
    }).await;
}

5) 재현 환경 만들기: “누수 의심 코드”를 최소 단위로 줄이기

운영에서만 터지는 누수는 대부분 다음 중 하나입니다.

  • 특정 입력에서만 종료 조건이 깨짐
  • 특정 외부 의존성 장애에서만 타임아웃 없이 대기
  • 특정 에러 경로에서 sender/receiver가 닫히지 않음

재현을 위해 추천하는 접근은 트래픽 리플레이보다 먼저 “의심 경로만” 떼어내는 것입니다.

  1. task 생성 지점을 하나씩 라벨링
  2. 해당 task가 의존하는 리소스(채널, 소켓, cancel token)를 명시적으로 주입
  3. 종료 조건을 테스트로 검증

5.1 종료 보장 테스트 예시

#[tokio::test]
async fn worker_exits_on_channel_close() {
    let (tx, rx) = tokio::sync::mpsc::channel::<u8>(8);
    let cancel = tokio_util::sync::CancellationToken::new();

    let h = tokio::spawn(async move {
        super::worker(rx, cancel).await;
    });

    drop(tx); // 채널 닫기

    // 일정 시간 내 종료 보장
    tokio::time::timeout(std::time::Duration::from_secs(1), h)
        .await
        .expect("worker did not exit");
}

이런 테스트는 “task 누수”를 기능 테스트 레벨에서 예방하는 데 효과적입니다.

6) 운영 진단 루틴: 발견부터 수정까지의 순서

실전에서는 아래 순서가 가장 시간 대비 효율이 좋았습니다.

6.1 1단계: 메모리 증가가 진짜 누수인지 분류

  • 큐 적체인가(트래픽과 함께 오르고, 트래픽이 줄면 내려오는가)
  • 캐시/맵이 커지는가(키 수가 증가하는가)
  • task 수가 증가하는가(증가율이 비정상인가)

6.2 2단계: task 생성 지점 Top-N 찾기

  • spawn 호출부에 라벨링/카운터 추가
  • 라벨별 “생성 수 - 종료 수” 차이를 메트릭으로

6.3 3단계: 종료 조건/취소 경로 강제

  • 모든 장수 task에 CancellationToken을 주입
  • 채널 recv 루프는 None 처리로 종료
  • 모든 외부 호출에 timeout 적용

6.4 4단계: 리소스 상한 설정

  • 동시성 제한(Semaphore)
  • 큐 길이 제한(bounded channel)
  • 재시도 상한/백오프

6.5 5단계: 장애 시나리오에서의 행동을 문서화

“외부 시스템이 10분간 응답이 없다” 같은 상황에서 어떤 task가 어떻게 종료/취소되는지 명확히 해야 합니다. 이건 성능 이슈의 장기 추적 방법론과도 유사합니다. 브라우저에서 Long Task를 추적하듯, 서버에서도 ‘긴 생존 task’를 추적하는 관점이 필요합니다: Chrome INP 점수 급락? Long Task 추적·해결

7) 흔한 안티패턴과 교정 요약

  • 안티패턴: tokio::spawnJoinHandle 방치

    • 교정: TaskGroup/JoinSet으로 수명 관리
  • 안티패턴: 채널 종료 시 None을 무시

    • 교정: None이면 루프 종료
  • 안티패턴: 외부 호출에 타임아웃 없음

    • 교정: tokio::time::timeout 기본 적용
  • 안티패턴: unbounded 채널로 “편하게” 연결

    • 교정: bounded + 백프레셔 + 드롭 정책
  • 안티패턴: Arc가 서로를 물고 늘어지는 설계

    • 교정: Weak로 끊기, 콜백 저장 구조 재검토

8) 결론: task 누수는 ‘종료 설계’의 문제다

Tokio에서 task 누수로 RAM이 폭증하는 문제는 “Rust는 메모리 안전하니까 누수 없겠지”라는 기대와 달리, 대부분 종료 조건/취소/백프레셔가 빠져서 발생합니다. 해결의 핵심은 다음 3줄로 요약됩니다.

  • 모든 task는 종료 조건이 있어야 한다(채널 종료, cancel token, 타임아웃)
  • 모든 spawn은 관측 가능해야 한다(라벨링, 생성/종료 카운터)
  • 모든 큐/재시도/동시성은 상한이 있어야 한다

이 3가지를 코드 규칙으로 박아두면, “어느 날 갑자기 RAM이 치솟는” 종류의 장애는 재발 확률이 크게 줄어듭니다.