녹음 post-mix 손상 stem — 14GB 거대 MP3 무한 인코딩·디스크 100% 폭주 원인 확정 hotfix PPI-1106 apps/socket

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

2026-07-06 ppi-socket-prod 디스크 100% 장애 PR #837 선행: PPI-1098(후처리 믹스 도입)

한 줄 요약

세션 중 stem ffmpeg 재시작 시 SIGKILL로 finalize되지 못한 2.8KB 손상 OGG가 ffprobe에서 start_time=0·duration≈17억초로 "성공적으로" 읽히며 기존 검증(null 체크·duration>0)을 모두 통과, post-mix 정렬 기준(base = min start_time)을 0(=1970년)으로 무너뜨렸다. 그 결과 정상 stem들의 adelayepoch값 그대로(≈56년) 계산되어 ffmpeg가 56년치 무음을 128kbps CBR로 무한 인코딩 — 디스크 20GB가 찰 때까지(14GB 시점 발견) 멈추지 않았다. hotfix는 손상 stem 격리(2겹 sanity check) + 폭주 상한(-t·타임아웃)의 4중 방어.

장애 현상 (ppi-socket-prod)

인과 사슬 — 5단계

  1. 정상 stem에는 epoch 타임스탬프가 박힌다. PPI-1098이 도입한 후처리 믹스 설계에서 stem OGG는 -use_wallclock_as_timestamps 1 -copyts로 기록되어 start_time이 절대 wallclock(예: 1,783,337,708초)이다. recordingManager.ts:953-955
  2. 손상된 2.8KB stem이 검증 두 겹을 모두 통과했다. collectValidStems()는 duration>0만 검사 — 손상 stem의 duration이 17억 초(양수)라 통과. postMixStems()의 가드는 start_time이 null인 stem만 드롭 — 이 stem은 0.0으로 "읽혔고" 0은 finite라 통과.
  3. base가 0으로 무너졌다. base = Math.min(...knownPairs.map(p => p.start)) = 0이 되면서 정상 stem들의 adelay가 start − 0 ≈ 1.78e12ms ≈ 56년으로 계산됐다.
  4. ffmpeg가 56년짜리 출력을 만들기 시작했다. adelay는 지연 구간을 무음 샘플로 채우고, amix duration=longest는 가장 긴 입력이 끝날 때까지 출력한다. ffmpeg 입장에선 오류가 아니라 유효한 명령의 정상 수행이다.
  5. 멈출 장치가 없었다. post-mix에 -t(출력 길이 상한)도 실행 타임아웃도 없어 await가 무한정 대기 — 유일한 종료 조건이 "디스크 가득참"이었다.
핵심: 거대 MP3의 내용물은 손상 stem이 반복 병합된 게 아니라 거의 전부 인코딩된 무음이다. 손상 stem(2.8KB)의 역할은 오디오 기여가 아니라 정렬 기준을 1970년으로 끌어내린 것이고, 디스크를 채운 실체는 정상 stem 앞에 붙은 56년치 무음 패딩이다.

손상 stem은 왜 생기고, 왜 타임스탬프가 1970인가

발생 경로 — kill 기반 재시작의 부산물

"1970년"의 정체 — 기록된 값이 아니라 소실된 값의 기본치

함정: wallclock 타임스탬프 설계에서는 "타임스탬프 없음"과 "1970년"이 구분되지 않는다. 0은 finite한 유효값처럼 보여 null 체크·finite 체크를 전부 통과한다. 그래서 수정은 "0인지"가 아니라 "녹음 시작 wallclock 근방인지"를 기준으로 판정한다.

왜 기존 검증을 통과했나

검증 지점검사 내용(수정 전)손상 stem의 값결과
collectValidStems 크기size > 02.8KB통과
collectValidStems durationprobe 성공 && durMs > 0≈1,783,337,868,000ms (epoch급 양수)통과 (상한 검사 없음)
postMixStems start_timenull 아님 && finite0.0 (finite)통과 (범위 검사 없음)
post-mix 출력 상한(없음)방어선 부재

필터들이 "읽을 수 있는가"만 봤지 "값이 말이 되는가"는 안 봤다. 대부분의 SIGKILL 잘림은 probe 실패·duration 0으로 기존 필터에 걸렸기에 평소 합본은 정상이었고, "검사 항목마다 그럴듯한 값을 돌려주는" 이 형태만 처음으로 뚫고 들어왔다.

거대 MP3가 만들어진 원인 — 내용물은 56년치 무음

무엇이 만들어지고 있었나

거대 MP3는 손상 stem이 반복해서 합쳐진 결과가 아니다. 손상 stem(2.8KB)은 내용 기여가 거의 없고, 역할은 정렬 기준(base)을 1970년으로 끌어내린 것뿐이다. 파일을 채운 실체는 정상 stem 앞에 붙은 무음 패딩 — post-mix ffmpeg 한 번의 실행이 끝나지 않고 무음을 계속 인코딩한 결과다.

// base=0(손상 stem의 start_time)으로 계산된 출력 타임라인
1970-01-01 ──[손상 stem 2.8KB, adelay=0]──┐
             │                            │
             │   ≈56년치 무음 패딩          │  ← adelay=1783337708425ms 가 만든 갭
             │   (adelay 가 무음 샘플로 채움) │     ffmpeg 는 이 구간을 성실히 인코딩 중이었다
             │                            │
2026-07-06 ──[정상 stem들: 실제 수업 오디오]─┘  ← 타임라인 맨 끝에야 등장
ffmpeg 입장에선 100% 정상 동작이다. ffmpeg는 epoch 개념을 모른다. 받은 필터 그래프가 "이 입력 앞에 1.78조 ms 무음을 붙여라(adelay)", "가장 긴 입력이 끝날 때까지 출력해라(amix duration=longest)"라고 지시했고, 2초든 56년이든 문법상 똑같이 유효한 파라미터라 시킨 일을 수행하고 있었을 뿐이다. 버그도 행(hang)도 아니어서 스스로 죽지도 않는다. 그 "56년"이라는 숫자를 만들어낸 게 우리 코드의 버그다 — 정상 start_time(epoch) − base(0) = epoch값 그대로가 adelay 로 번역.

왜 계속 커졌나 — 멈출 조건이 하나도 없었다

그 14GB를 열어보면 앞부분부터 수십 년치 무음이며, 인코딩이 무음 구간을 다 지나지 못하고 발견됐으므로 실제 수업 오디오는 아예 담기지 못한 순수 무음 파일일 가능성이 높다.

우연인가 필연인가 — 스위스 치즈 3조건

조건성격설명
① SIGKILL 잘림 OGG 발생상시 (구조)producer 교체마다 짧은 타임아웃 kill — 잘린 stem은 운영 중 계속 생성
② finite면 신뢰하는 정렬 로직상시 (PPI-1098 이후)base=min(start_time)이 배포된 순간부터 모든 합본이 이 코드를 통과
③ 비대칭 손상 형태확률 (유일한 우연)하필 "start_time=0 + epoch 꼬리"로 깨지는 바이트 경계에서 kill

"언제 터질지"만 우연이었고 "언젠가 터진다"는 예정돼 있었다. 수업이 매일 돌아가는 이상 ③의 확률은 누적된다. 참고로 base=min(start_time) 정렬은 최근 PPI-1098에서 도입됐으므로, 그 이전이었다면 같은 stem이 섞여도 폭주로는 이어지지 않았다 — 새 정렬 설계 + 신종 손상 형태가 만나 처음 터진 사이드 이펙트다.

수정 — 4중 방어 (PPI-1106, PR #837)

목표: 손상 stem 발생 자체를 막는 게 아니라(③은 통제 불가), 들어와도 피해가 "그 stem 하나 누락"에서 멈추도록 격리하고, 로직이 또 뚫려도 디스크 장애로 못 자라게 상한을 둔다.

1) duration sanity — collectValidStems

isPlausibleStemDurationMs(): duration이 12시간을 초과하는 stem은 타임스탬프가 깨진 것으로 보고 드롭. 이번 손상 stem(17억 초)은 여기서 바로 걸러진다.

2) start_time sanity — planPostMix 순수 함수 추출

기존 pairing/base 계산을 순수 함수로 추출하면서, start_time이 녹음 시작 wallclock ±12시간을 벗어난 stem(start_time=0 등)을 null과 동일하게 믹스에서 제외 — base 붕괴 경로 자체를 차단. 유효 stem이 0개면 기존 전부-null 폴백과 동일(전부 0ms 정렬, t0=녹음 시작 wallclock). 캡션 좌표는 mp3T0Ms만 참조하므로 plan 수정으로 함께 치유된다.
// start_time 이 null 이거나 녹음 시작 wallclock 에서 ±12h 벗어난 stem(손상 OGG 의
// start_time=0 등)은 드롭해 base 붕괴 → epoch 급 adelay → 무한 인코딩을 차단한다.
export function planPostMix(startTimesSec, recordingStartWallMs): PostMixPlan {
  // plausible = null 아님 && finite && |startMs - recordingStartWallMs| <= 12h
  // plausible 0개  → 전부 0ms 폴백 (usedFallback=true, t0=recordingStartWallMs)
  // plausible 있음 → base = min(plausible), 나머지는 droppedIndices 로 제외
}

3) 출력 길이 상한 — buildPostMixArgs

post-mix ffmpeg에 -t 21600(6시간) 추가. 정렬 로직이 미지의 새 손상 형태에 또 뚫려도 mp3가 최대 ≈330MB(128kbps×6h)에서 잘린다. spawn 인자 빌드도 순수 함수로 추출해 테스트 대상화.

4) 실행 시간 타임아웃

post-mix ffmpeg가 15분을 넘으면 SIGKILL + reject. 정상 세션 인코딩은 수 분 이내이므로 초과는 폭주로 간주.

바꾸지 않은 것

테스트 (TDD)

운영 조치 (코드 픽스와 별개)

이 픽스는 재발 방지용이다. prod 서버 복구는 별도 필요: ① 돌고 있는 post-mix ffmpeg PID kill → ② 14GB MP3 및 관련 임시 stem 파일 삭제 → ③ hotfix 배포. (2026-07-06 문서 작성 시점: PpiDeveloper 권한으로 SSM SendCommand/StartSession·CloudWatch가 모두 거부되어 서버 정리 완료 여부는 CLI로 직접 확인하지 못함 — 인프라 권한 보유자의 확인 필요)

관련 문서 · 코드