마지막 업데이트 2026-07-29
녹음 후처리 CPU Spike 분석 및 큐 처리 개선의 핵심을 수사반장식으로 먼저 안내합니다. 기술적 결론과 원문 근거는 아래 본문에 보존되어 있습니다.
ConcurrencyLimiter와 degraded 시 consumer pause로 개선할 것을 제안한다.
이 사건은 단일 사고가 아니라 지목이 잘못된 그래프에서 출발한다. "후처리(transcode)가 워커 CPU를 올렸다"는 심증을 검증하려면, 먼저 그 그래프가 실제로 무엇을 재는지부터 확인해야 했다.
"Mediasoup 워커 CPU" 대시보드는 다음 메트릭을 rate로 그린다 (apps/socket/src/metrics.ts):
mediasoupWorkerCpu
.labels(String(stat.pid))
.set(stat.usage.ru_utime / 1000000); // worker.getResourceUsage() 의 ru_utime (누적 user CPU 초)
즉 mediasoup 워커(C++) 프로세스의 누적 user CPU 시간이다. 따라서 이 그래프에 잡히는 것은 오직 워커가 수행하는 RTP 라우팅·복호화·포워딩 작업이다.
transcodeToMp3(libmp3lame), concatSegments, ffprobe는
모두 spawn()으로 띄우는 별도 자식 프로세스(다른 PID)다.
이들은 워커와 다른 프로세스이므로 이 그래프에 절대 나타나지 않는다.
"후처리(transcode)가 워커 CPU 그래프를 올렸다"는 메트릭상 성립하지 않는다.
메트릭 확인으로 첫 용의자(후처리 → 워커 CPU)의 알리바이가 성립했다. 그렇다면 워커 CPU와 호스트 CPU 각각을 튀게 한 진짜 요인을 따로 추적해야 한다 — 워커 쪽 둘(정상 부하·dead-port 낭비), 호스트 쪽 둘(세마포어 레이스·무제한 ffprobe).
녹음 1세션은 child + ai plain transport consumer 2개가 producer → ffmpeg UDP로 상시 RTP를 중계한다.
그래프의 저녁(17~21시) 피크는 수업 동시 진행 피크 = plain transport 피크로, 대부분 정상 부하다.
createPlainTransportConsumer()가 그 생성 지점이다.
ffmpeg가 죽었는데도 plain transport consumer가 resume 상태로 남으면, 워커는 producer에서 RTP를 계속 끌어와
아무도 듣지 않는 UDP 포트로 수업 종료까지 포워딩한다. → 워커 CPU 낭비 + ICMP port-unreachable 처리.
status="error"가 되면 stopRecording이 early-return하여
transport가 영구 leak되었고, RTP 포워딩이 끝나지 않아 누적되었다. 저녁 내내 CPU가 우상향하다
특정 시점에 급락하는 패턴(세션 일괄 종료/재시작)과 들어맞는다.
기존 TranscodeSemaphore는 acquire가 await 이후에 running++하고,
release가 먼저 running--했다. 이 사이 윈도우에서 새 acquire가 통과해
maxConcurrency=2를 초과하는 동시 transcode가 발급될 수 있었다.
release(): running-- // 슬롯 반납
→ 대기자 깨움 (continuation은 microtask로 지연)
↑↑↑ 이 윈도우
새 acquire(): running < max 통과 → running++ // 슬롯 초과 발급!
깨어난 대기자: running++ // 결과적으로 3개 동시 실행
libmp3lame transcode는 CPU 무거운 작업이라, 피크에 2개 제한을 넘기면 호스트 CPU가 튄다.
buildAndUploadCaptions가 세그먼트 수만큼 ffprobe를 동시에 무제한 spawn했다.
activity 전환이 많은 긴 수업은 세그먼트가 10~20개이고, 여러 수업이 동시에 끝나면 곱연산으로
ffprobe 프로세스가 폭증해 후처리 CPU 버스트를 일으킨다.
// (이전) 무제한 병렬
const segments = await Promise.all(
session.oggSegments.map(async (seg) => ({
...seg,
audioDurationMs: (await probeAudioDurationMsSafe(seg.filePath)) ?? 0,
})),
);
호스트 CPU를 튀게 한 두 범인(over-admission 레이스, 무제한 ffprobe)은 같은 수법 — 동시 프로세스 수 통제 실패 — 을 쓴다. 그래서 검거 도구도 하나로 통일한다.
후처리에서 동시에 spawn되는 프로세스 수를 엄격히 제한하기 위해, 슬롯 인계형
ConcurrencyLimiter(카운팅 세마포어) 하나로 통일하는 것을 제안한다.
(아래 코드는 제안 설계이며 현재 브랜치에는 반영되어 있지 않다.)
class ConcurrencyLimiter {
private running = 0;
private queue: Array<() => void> = [];
constructor(private readonly maxConcurrency: number) {}
async acquire(): Promise<void> {
if (this.running < this.maxConcurrency) {
this.running++; // 빈 슬롯 즉시 점유
return;
}
// 슬롯은 release()에서 인계받으므로 여기서 running을 다시 증가시키지 않는다.
await new Promise<void>((resolve) => this.queue.push(resolve));
}
release(): void {
const next = this.queue.shift();
if (next) {
next(); // 대기자에게 슬롯을 그대로 인계 (running 유지)
} else {
this.running--; // 대기자 없을 때만 슬롯 반납
}
}
}
running은 항상 정확하고
maxConcurrency를 절대 초과하지 않는다. over-admission 레이스가 제거된다.
| 큐 | 대상 | 동시도 | 방어하는 spike |
|---|---|---|---|
transcodeSemaphore |
mp3 인코딩 (nice -n 19 ffmpeg) |
TRANSCODE_MAX_CONCURRENCY = 2 |
피크 동시 종료 시 lame 폭증 |
probeSemaphore |
세그먼트 길이 측정 (ffprobe) |
FFPROBE_MAX_CONCURRENCY = 4 |
세그먼트 다수 측정 시 프로세스 폭증 |
operationChain |
세션 내 변경작업 직렬화 (replace/transition) | 세션당 1 (순차) | (정합성용 — CPU와 별개) |
// transcode
await this.transcodeSemaphore.acquire();
try { /* spawn nice ffmpeg ... */ } finally { this.transcodeSemaphore.release(); }
// ffprobe — Promise.all은 순서를 보존, 실제 동시 spawn은 4개로 제한
const segments = await Promise.all(
session.oggSegments.map(async (seg) => {
await this.probeSemaphore.acquire();
try {
return { ...seg, audioDurationMs: (await probeAudioDurationMsSafe(seg.filePath)) ?? 0 };
} finally {
this.probeSemaphore.release();
}
}),
);
release()는 반드시 finally에서 호출한다. 작업이 throw해도 슬롯이 새지 않아
큐 데드락을 방지한다.
CPU spike와는 별개로, activity 전환 같은 세션 변경 작업은 operationChain으로 세션별 직렬화된다.
replaceChildConsumer/replaceAiConsumer가 동시에 들어와도 한 세션 안에서는 순차 실행되어
transport 생성/교체가 겹쳐 꼬이지 않는다. (전역 동시도 제어인 ConcurrencyLimiter와 달리 세션 내부 순서 보장용.)
남은 것은 워커 쪽 낭비범(dead-port RTP 포워딩). 죽은 ffmpeg를 향해 계속 흘러가는 RTP를 끊는 것이 처방이다.
ffmpeg가 죽는 경우(spawn error / 비정상 exit)에 세션을 degraded로 표시하면서
pauseConsumers()를 호출해, 죽은 UDP 포트로의 RTP 포워딩(워커 CPU 낭비)을 즉시 중단하도록 제안한다.
ffmpeg.on("exit", (code, signal) => {
// ...의도된 종료는 signal / status 가드로 제외...
if (!isIntentional && isActiveFfmpeg && session.status === "recording") {
session.degraded = true; // status는 "recording" 유지 → stopRecording이 앞 세그먼트 salvage
void this.pauseConsumers(session); // dead-port RTP 포워딩 중단 (워커 CPU 절약)
}
});
transitionFfmpeg의 resume 실패는 새 ffmpeg가 살아있어 한쪽 입력이라도 살릴 수 있으므로,
dead-port 낭비가 아닌 케이스로 보고 pause하지 않는다.
| 질문 | 답 |
|---|---|
| 후처리(transcode)가 워커 CPU 그래프 spike의 원인인가? | ❌ 아님. transcode/ffprobe는 별도 프로세스라 워커 CPU 그래프에 잡히지 않는다. |
| 그래프의 워커 CPU spike 실제 원인 | 동시 녹음 수 × plain transport RTP 포워딩(정상 부하) + dead-port 포워딩/leak. |
| 큐 처리 문제 여부 | ✅ 있음(미수정) — 세마포어 over-admission 레이스 + 무제한 병렬 ffprobe. 둘 다 호스트 CPU spike 유발. |
| 구분 | 개선 | 효과 |
|---|---|---|
| 워커 | degraded(ffmpeg 사망) 시 pauseConsumers |
dead-port RTP 포워딩 중단 → 워커 CPU 낭비 제거 |
| 호스트 | ConcurrencyLimiter 슬롯 인계 방식 |
transcode 동시도 2 엄격 보장 → over-admission 제거 |
| 호스트 | ffprobe probeSemaphore(4) 제한 |
세그먼트 다수 동시 종료 시 프로세스 폭증 완화 |