좀비 job 슬롯 점유 — 같은 ECS 태스크 수업 job 지연 · AI 미발화 원인 분석 피해 범위 확정 촉발 요인 미확정 수정 미적용

마지막 업데이트 2026-09-17

쉬운 설명 한 장 보기 · 주방 비유로 읽는 요약

작성일: 2026-09-16 사건일: 2026-09-15 17:32~18:12 KST 이슈 ID: 미지정 범위: apps/livekit-agent · ECS ppi-livekit-agent

결론

워커 프로세스가 스스로 나빠진 "열화"가 아니다. 테스트 계정의 방치된 job이 워커 슬롯 2개 중 1개를 3시간 5분 점유해, 그 워커에 배정된 모든 실수업 job이 예외 없이 "두 번째 동시 실행 job"이 된 상태에서 오디오 파이프라인이 실시간을 못 따라갔고, TTS가 세션 종료 전에 도달하지 못했다.
다만 좀비 job이 자원을 독식한 "가해자"라는 근거는 없다. 17:20까지는 같은 "동시 2개" 조건에서 정상이었고, 좀비 사후에도 동시 2개면 경미한 지연이 남는다. 17:32경 컨테이너 여력이 임계를 넘긴 별도 요인은 미확정이다.

증상 신고는 손범 1건뿐이었으나, 같은 워커에서 밀린 수업 job은 24개다.

시간순 인과

15:08:03  테스트 계정 '테스트아리'(d9ed7fb0…)가 수업 세션 열고 방치
          → job AJ_CLYSMmWdT876 (pid 720, activity newpp01_up_wjv1) 생성
          → 워커 AW_eg5RnPhNXC2r 의 슬롯 2개 중 1개를 계속 점유
15:28:04  realtime input audio commit correlation quarantined; reconnecting × 9
          → 고장 신호가 아니다. 20분 주기 커넥션 재활용(DEFAULT_MAX_SESSION_DURATION
            = 20분)에 따른 정상 로그로, 모든 job 이 20분마다 찍는다.
          → 좀비 신호는 이 로그가 아니라 "이 로그 뒤로 대화 활동이 없다"는 점이다.
16:00:41  inference is slower than realtime 누적 시작 (livekit.plugins.silero)
17:20:03  이 시점 잡까지는 정상 (경고 0건)          ← 촉발 요인 이전
17:32:53  이후 이 워커에 들어온 수업 job 24개 전부 실시간 추종 실패
          → 실효 처리율 1~6% · 최대 적체 5.9~83.6초
          → LLM 텍스트는 늦게라도 도착, TTS 첫 프레임은 세션 종료 전 미도달
          → 아동 화면: "핑퐁이가 말을 안 해요"
18:12:48  방치 페이지 닫힘 → participant disconnect → 좀비 job 종료
          → 이후 신규 job 정상

확정된 사실

사실근거
좀비 job 3시간 4분 45초 생존 CloudWatch /ppi/livekit/prod/agentreceived job request 15:08:03 → process exiting 18:12:48. 종료 사유 closing agent session due to participant disconnect
밀린 수업 job 24개가 전부 같은 ECS 태스크 @logStream agent/ppi-livekit-agent/b9238a5f30f04c3a9207df1601ba88bf 24/24 일치. 이 스트림 = 워커 AW_eg5RnPhNXC2r
실시간 추종 실패 inference is slower than realtimedelay 필드(누적 적체 초). 실효 처리율 realtime 대비 1~6%
TTS 미도달 손범 51회기 게스트 lesson-log — 실패 4룸에서 agent_response_started·agent_transcript_done은 있고 tts_provider_stream_started·tts_node_first_audio_frame·agent_response_completed는 0건
워커 단위로 갈린다 손범 19룸 중 실패 4룸은 전부 이 워커, 정상 15룸은 전부 다른 워커 9대 (우연 확률 0.026%)
시간대 요인 아님 박준호 1회기는 같은 창(17:33~17:48)·같은 진행자인데 다른 워커 5대에서 응답 57건 전부 정상 (첫 TTS 3.0~3.6초)
좀비 종료 = 회복 18:12:31 마지막 지연 경고 → 18:12:48 process exiting → 이후 정상

미확정 — 17:32에 무엇이 임계를 넘겼나

좀비 job 한 개만으로는 설명되지 않는다. 아래 세 가지가 반증이다.
2026-09-17 전수 확인 — 반증 1이 표본 3건에서 47건 전수로 확장됐다. 좀비 생존 구간(15:08:03~18:12:48)의 동거 job은 47건이고, 17:32:53 이전 24건은 경고 0건, 이후 23건 중 22건이 피해(96%)였다. 동거 조건이 동일한데 결과가 0%와 96%로 갈리므로 17:32 촉발 요인의 존재는 계량적으로 확정된다. 전수 표와 재현 쿼리는 동거 수업 job 전수 목록 참고.

남은 후보: 태스크 CPU 크레딧 소진 · 좀비 프로세스의 메모리 누적 · 호스트 경합. 확정하려면 해당 ECS 태스크의 17:00~18:30 CPU 할당량과 CPUUtilization·MemoryUtilization이 필요하다.

기각한 가설

가설기각 근거
워커 프로세스 열화 (메모리 누수 등 비가역 악화)재시작 없이 18:12:48에 자가 회복. 누적성 열화는 저절로 낫지 않음
호스트 이그레스(DNS·conntrack) 불량지연이 CPU 기아 지표(VAD 추론)에 직접 나타남. 네트워크 구간 단독으로는 VAD 추론 지연을 설명 못 함
Typecast TTS 서비스 장애같은 시각 다른 워커 15룸에서 TTS 정상
httpx 커넥션 풀 오염_build_tts()가 세션 구성마다 호출되어 job마다 새 AsyncClient 생성 (agent.py:1156,13284507)
Consumer suspected silent 경고전 건이 trigger:"consumer-created" 워밍업 프로브. ai-audio 컨슈머 40개 중 40개에서 뜨고 정상 세션도 동일하게 audioEnergyDelta:0
not dispatching agent job since no worker is available 586건JT_PUBLISHER/JT_PARTICIPANT 노이즈. ppi-agent는 JT_ROOM으로만 등록
job ended JS_FAILED "agent worker left the room" 292건전 세션 정상 종료 패턴. job 프로세스 크래시는 error가 빈 문자열

코드 경로

워커가 계속 배정받은 이유 — apps/livekit-agent/agent.py:344-373

PPI_AGENT_MAX_JOBS_PER_WORKER = 2          # 환경변수 미설정 시 기본값

def _ppi_agent_load(server):
    active_jobs = len(server.active_jobs)
    return min(active_jobs / PPI_AGENT_MAX_JOBS_PER_WORKER, 1.0) * PPI_AGENT_LOAD_THRESHOLD

server = AgentServer(load_fnc=_ppi_agent_load, load_threshold=0.99, num_idle_processes=2)

load 계산에 job 개수만 들어간다. 좀비 1개가 슬롯을 물고 있어도 load는 1/2 × 0.99 = 0.495라 LiveKit 눈에는 "절반 빈 여유 워커"다. 수업 job이 끝나면 즉시 다시 후보가 되므로 끊임없이 재선택된다 — 손범이 4번이나 이 워커를 다시 만난 이유다. 17:48:48의 failed to assign job to worker: worker not available은 그 순간 2슬롯이 다 찼다는 뜻이며, 이 워커 고유의 신호가 아니다(같은 시간대 다른 워커에도 발생).

TTS 미도달 판정 지점 — apps/livekit-agent/typecast_tts.py:259-282

tts_provider_stream_started는 Typecast HTTP 응답이 200 + content-type audio일 때만 발행된다. 실패 룸에서 이 이벤트가 0건이라는 것은 응답 헤더 수신 이전 단계에서 멈췄다는 뜻이다. 실패 시 typecast_tts_synthesis_failederror_class·http_status·elapsed_ms로 네트워크/제공자/타임아웃을 가른다(클라이언트 timeout=30.0, typecast_tts.py:88).

delay 필드의 정의 — livekit/plugins/silero/vad.py:452-461

inference_duration = time.perf_counter() - start_time
extra_inference_time = max(0.0, extra_inference_time + inference_duration - window_duration)
if inference_duration > SLOW_INFERENCE_THRESHOLD:   # 0.2초
    logger.warning("inference is slower than realtime", extra={"delay": extra_inference_time})

window_duration은 512샘플 / 16kHz = 32ms. 즉 delay는 "오디오를 실시간보다 늦게 처리한 초과분의 누적합"이고, 경고 건수는 한 윈도 추론이 0.2초를 넘은 횟수다. 이 값은 _reset_state()에서 0으로 초기화되므로 톱니 모양 하강을 곧바로 "회복"으로 읽으면 안 된다.

판별 절차 (재사용)

  1. Grafana CloudWatch 로그그룹 /ppi/livekit/prod/agent에서 filter @message like /inference is slower than realtime/ 로 전량 수집. 조회 범위는 사건 시각보다 최소 3시간 앞까지 넓힌다 — 좀비 job의 시작점이 창 밖에 있으면 원인 job을 특정할 수 없다.
  2. job_id·pid별로 건수·최대 delay·room 집계. 건수가 수백이고 생존이 수십 분이면 상주 job, 건수 수십에 생존 수십 초면 피해 job, 1~2건 0.2~0.8초는 정상 범위. 비교는 반드시 분당 발생률로 정규화한다.
  3. received job request 로 각 job의 @logStream을 확인해 동일 ECS 태스크인지 확정. 워커당 job 2개가 한 스트림에 섞이므로 room UUID 또는 job_id 필터가 필수다.
  4. 아동 피해 확정은 게스트 lesson-log에서 agent_response_started > 0 이면서 tts_node_first_audio_frame = 0 인 룸을 찾는다.
  5. 워커 매핑은 LiveKit 서버 로그 assigned job to workerAJ_*AW_* 조인.
함정: Typecast 관련 로그 payload에는 room 필드가 없다. room UUID로만 필터하면 TTS 로그가 통째로 누락된다 — job_id로 함께 걸어야 한다.

처방 제안 (미적용)

항목내용우선도
job 최대 수명 제한실수업 세션은 1시간을 넘지 않는다. 그 이상 생존한 job은 강제 종료. 15:28에 세션이 이미 비정상이었는데도 2시간 44분 더 살아남은 것이 이번 사건의 출발점이다.P0
테스트 계정 격리테스트 job이 운영 워커 풀을 공유하는 구조 자체가 위험. 전용 워커 풀 또는 별도 agent name으로 분리.P1
동시 실행 수 재검토좀비 사후에도 동시 2개에서 5.9초 지연이 남는다. MAX_JOBS_PER_WORKER=2가 실제 CPU 여력 대비 과다한지 태스크 메트릭으로 확인.P1
load에 부하 실측 반영현재 load는 job 개수만 본다. 실시간 추종 실패가 연속 감지되면 load를 올려 신규 job 수락만 중단(self-drain)하고 진행 중 세션은 유지.P2
관측워커별 "응답 대비 TTS 첫 프레임 도달률" 대시보드. 현재 이 실패는 어떤 알람에도 걸리지 않는다.P2

이슈 히스토리 · 주의사항

참고 자료