마지막 업데이트 2026-07-22
ConcurrencyLimiter와 degraded 시 consumer pause로 개선할 것을 제안한다.
"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 그래프를 올렸다"는 메트릭상 성립하지 않는다.
녹음 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,
})),
);
후처리에서 동시에 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와 달리 세션 내부 순서 보장용.)
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) 제한 |
세그먼트 다수 동시 종료 시 프로세스 폭증 완화 |