- Published on
Rust Tokio task 누수로 RAM 폭증 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 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 $PIDpmap -x $PID | tail -n 20(맵 증가 패턴)- 컨테이너라면 cgroup 메모리 사용량
메모리 폭증이 실제로 OOM으로 이어진다면, 앞서 링크한 OOM 진단 글처럼 커널이 누구를 왜 죽였는지를 먼저 고정 증거로 확보하세요.
3) “어떤 task가 안 끝나는가”를 찾는 방법
Tokio task는 스레드처럼 gdb로 스택을 한 번에 보기 어렵습니다. 대신 다음 3가지 축으로 좁힙니다.
- task에 이름/ID/라벨을 붙여 로그 상관관계를 만든다
tracing으로 생명주기(생성~종료) 이벤트를 남긴다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가 닫히지 않음
재현을 위해 추천하는 접근은 트래픽 리플레이보다 먼저 “의심 경로만” 떼어내는 것입니다.
- task 생성 지점을 하나씩 라벨링
- 해당 task가 의존하는 리소스(채널, 소켓, cancel token)를 명시적으로 주입
- 종료 조건을 테스트로 검증
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::spawn후JoinHandle방치- 교정: TaskGroup/JoinSet으로 수명 관리
안티패턴: 채널 종료 시
None을 무시- 교정:
None이면 루프 종료
- 교정:
안티패턴: 외부 호출에 타임아웃 없음
- 교정:
tokio::time::timeout기본 적용
- 교정:
안티패턴: unbounded 채널로 “편하게” 연결
- 교정: bounded + 백프레셔 + 드롭 정책
안티패턴:
Arc가 서로를 물고 늘어지는 설계- 교정:
Weak로 끊기, 콜백 저장 구조 재검토
- 교정:
8) 결론: task 누수는 ‘종료 설계’의 문제다
Tokio에서 task 누수로 RAM이 폭증하는 문제는 “Rust는 메모리 안전하니까 누수 없겠지”라는 기대와 달리, 대부분 종료 조건/취소/백프레셔가 빠져서 발생합니다. 해결의 핵심은 다음 3줄로 요약됩니다.
- 모든 task는 종료 조건이 있어야 한다(채널 종료, cancel token, 타임아웃)
- 모든 spawn은 관측 가능해야 한다(라벨링, 생성/종료 카운터)
- 모든 큐/재시도/동시성은 상한이 있어야 한다
이 3가지를 코드 규칙으로 박아두면, “어느 날 갑자기 RAM이 치솟는” 종류의 장애는 재발 확률이 크게 줄어듭니다.