녹음 후처리 CPU Spike 분석 및 큐 처리 개선

마지막 업데이트 2026-07-22

대상: apps/socket/src/recording/recordingManager.ts · 관련 이슈: PPI-994 · 환경: mediasoup SFU 미디어 서버 (prod)

상태: 분석 + 개선 제안 (코드 미반영). 아래 개선안은 아직 코드에 반영되지 않았고 다음 작업으로 진행 예정이다. 본 문서는 원인 분석과 제안 설계를 기록한 것으로, 현재 브랜치 코드와는 일치하지 않을 수 있다.
한 줄 요약. mediasoup 워커 CPU 그래프에 잡히는 부하는 RTP 포워딩이며, transcode/ffprobe 같은 후처리는 별도 프로세스라 그 그래프에 잡히지 않는다. 후처리 CPU spike는 ① 잘못된 세마포어로 인한 동시 transcode 초과 발급, ② 무제한 병렬 ffprobe가 호스트 CPU를 튀게 한 것이 핵심이고, 워커 CPU 낭비는 ③ 죽은 ffmpeg로의 dead-port RTP 포워딩에서 발생한다. 이를 슬롯 인계형 ConcurrencyLimiterdegraded 시 consumer pause로 개선할 것을 제안한다.
목차

1. 이 그래프가 측정하는 것 (ru_utime)

"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 그래프를 올렸다"는 메트릭상 성립하지 않는다.

2. 원인 분석 — 워커 CPU vs 호스트 CPU

워커 CPU 정상 부하 — 동시 녹음 수 × plain transport 2개

녹음 1세션은 child + ai plain transport consumer 2개가 producer → ffmpeg UDP로 상시 RTP를 중계한다. 그래프의 저녁(17~21시) 피크는 수업 동시 진행 피크 = plain transport 피크로, 대부분 정상 부하다. createPlainTransportConsumer()가 그 생성 지점이다.

워커 CPU dead-port RTP 포워딩 (실제 낭비)

ffmpeg가 죽었는데도 plain transport consumer가 resume 상태로 남으면, 워커는 producer에서 RTP를 계속 끌어와 아무도 듣지 않는 UDP 포트로 수업 종료까지 포워딩한다. → 워커 CPU 낭비 + ICMP port-unreachable 처리.

이전 코드에서 죽은 ffmpeg가 status="error"가 되면 stopRecording이 early-return하여 transport가 영구 leak되었고, RTP 포워딩이 끝나지 않아 누적되었다. 저녁 내내 CPU가 우상향하다 특정 시점에 급락하는 패턴(세션 일괄 종료/재시작)과 들어맞는다.

호스트 CPU 세마포어 over-admission 레이스 (큐 버그)

기존 TranscodeSemaphoreacquireawait 이후에 running++하고, release가 먼저 running--했다. 이 사이 윈도우에서 새 acquire가 통과해 maxConcurrency=2를 초과하는 동시 transcode가 발급될 수 있었다.

release(): running--                 // 슬롯 반납
           → 대기자 깨움 (continuation은 microtask로 지연)
           ↑↑↑ 이 윈도우
새 acquire(): running < max 통과 → running++   // 슬롯 초과 발급!
깨어난 대기자: running++                          // 결과적으로 3개 동시 실행

libmp3lame transcode는 CPU 무거운 작업이라, 피크에 2개 제한을 넘기면 호스트 CPU가 튄다.

호스트 CPU 무제한 병렬 ffprobe

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,
  })),
);

3. 큐 처리 방식 (개선 제안)

후처리에서 동시에 spawn되는 프로세스 수를 엄격히 제한하기 위해, 슬롯 인계형 ConcurrencyLimiter(카운팅 세마포어) 하나로 통일하는 것을 제안한다. (아래 코드는 제안 설계이며 현재 브랜치에는 반영되어 있지 않다.)

3.1 슬롯 인계형 세마포어

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--;          // 대기자 없을 때만 슬롯 반납
    }
  }
}
핵심. release가 슬롯을 감소시키지 않고 대기자에게 직접 넘기므로 running은 항상 정확하고 maxConcurrency절대 초과하지 않는다. over-admission 레이스가 제거된다.

3.2 두 지점에 적용

대상동시도방어하는 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해도 슬롯이 새지 않아 큐 데드락을 방지한다.

3.3 세션 단위 직렬 큐 (참고)

CPU spike와는 별개로, activity 전환 같은 세션 변경 작업은 operationChain으로 세션별 직렬화된다. replaceChildConsumer/replaceAiConsumer가 동시에 들어와도 한 세션 안에서는 순차 실행되어 transport 생성/교체가 겹쳐 꼬이지 않는다. (전역 동시도 제어인 ConcurrencyLimiter와 달리 세션 내부 순서 보장용.)

4. degraded 시 consumer pause (제안)

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 절약)
  }
});
적용 범위. ffmpeg가 죽은 케이스(spawn error / 비정상 exit)에만 pause한다. transitionFfmpeg의 resume 실패는 새 ffmpeg가 살아있어 한쪽 입력이라도 살릴 수 있으므로, dead-port 낭비가 아닌 케이스로 보고 pause하지 않는다.

5. 결론 요약

질문
후처리(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) 제한 세그먼트 다수 동시 종료 시 프로세스 폭증 완화