Docker Compose로 운영 중인 자체 개발 라이브 스트림 녹화 도구(Python/FastAPI + yt-dlp 기반)에서 며칠에 걸쳐 겪은 문제들을 시간 순으로 정리한다. 겉으로는 서로 다른 증상이었지만, 되짚어보면 몇 가지 반복되는 패턴이 있었다.
1. 특정 채널만 녹화가 계속 실패한다
증상
ERROR: [youtube] VIDEO_ID: Requested format is not available. Use --list-formats for a list of available formats
특정 라이브 채널만 이 에러를 반복하며 녹화에 실패했다.
원인
화질(quality) 설정값을 yt-dlp 포맷 셀렉터로 변환하는 로직에 "lowest"(최저화질) 값을 처리하는 분기가 없었다. 그래서 아래처럼 정의되지 않은 값은 아무 변환 없이 그대로 통과되고 있었다.
else:
# 여기로 그대로 떨어짐
effective_quality = quality
yt-dlp가 이해하는 키워드는 worst이지 lowest가 아니라서, 존재하지 않는 format_id로 해석되어 매번 매칭 실패로 이어졌다.
해결
elif quality in ("worst", "lowest"):
effective_quality = "worstvideo+worstaudio/worst"
명시적으로 매핑하는 분기를 추가했다.
재발 방지
같은 유형의 문제가 재발했을 때 코드를 다시 읽지 않아도 되도록, 진단용 로그 3곳을 추가했다.
# quality → 실제 셀렉터 변환 결과
logger.info("[%s] quality='%s' -> effective_quality='%s'", platform, quality, effective_quality)
# 실제 실행되는 yt-dlp 커맨드 전체
logger.info("Recording %s cmd: %s", rec_id, " ".join(cmd))
# 실패 시 quality 상태를 포함한 에러 로그
logger.warning("Recording %s failed with exit code %d — quality='%s' effective_quality='%s'", rec_id, rc, quality, effective_quality)
이후로는 문제가 생기면 로그 한 줄 grep으로 원인 후보를 바로 좁힐 수 있게 됐다.
2. Docker 로그가 무한정 쌓인다
docker logs의 실체는 호스트에 저장되는 JSON 로그 파일인데, 기본 설정에는 로테이션이 걸려있지 않아 무한정 증가할 수 있는 구조였다. 각 서비스에 로그 로테이션을 추가했다.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
3. 일일 요약 메일에 “진행 중인 녹화 0건”으로 잘못 표시됨 (1차 원인)
증상
매일 자정 발송되는 요약 메일에서, 실제로는 자정 시점에 라이브 녹화가 진행 중이었는데도 “현재 녹화 중 – 0건”으로 표시됐다.
원인
앱의 상태 파일(state.json)을 별도 스크립트가 읽어 “진행 중” 레코드를 집계하는 구조였는데, 녹화가 시작되는 시점엔 상태 저장 함수 호출이 아예 없었다. 저장은 오직 (a) 시작 직후 곧바로 정지된 예외 케이스, (b) 녹화가 종료된 뒤 두 경우에만 일어났다. 즉 녹화가 몇 시간을 진행 중이어도 그 사이 상태 파일은 전혀 갱신되지 않는 구조였다.
해결
녹화 시작 처리부에 상태 저장 호출을 추가했다.
rec["status"] = "recording"
rec["started_at"] = time.time()
rec["log"] = []
_save_state() # 추가
4. 같은 증상 재발 — 2차 원인 (저장 필터에서 애초에 제외됨)
증상
1차 패치 이후에도 동일한 “진행 중 0건” 문제가 재발했다.
원인
상태 저장 함수 자체가 아래처럼 특정 상태값만 저장 대상으로 필터링하고 있었다.
saved_recordings = {}
for rid, rec in recordings.items():
if rec.get("status") in ("completed", "error", "archived"):
saved_recordings[rid] = rec
"recording" 상태는 이 필터에서 애초에 제외되고 있었다. 즉 1차 패치로 저장 호출은 정상 실행됐지만, 저장 함수 내부에서 그 값을 걸러내고 있었기 때문에 실질적으로 아무 효과가 없었다.
해결 (안전장치까지 포함)
필터에 진행 중 상태 포함:
if rec.get("status") in ("completed", "error", "archived", "recording", "starting"):
saved_recordings[rid] = rec
컨테이너가 비정상 종료(강제 종료, 정전 등)됐다가 재시작될 때, 파일에 좀비로 남은 “recording” 레코드를 자동으로 “error”로 정리:
if rec.get("status") in ("recording", "starting"):
rec["status"] = "error"
rec["error"] = "Interrupted by restart (no clean shutdown)"
rec.setdefault("ended_at", rec.get("started_at") or rec.get("created_at") or time.time())
진행 중 상태를 정직하게 저장하되, 재시작 시 유령 레코드가 영원히 남지 않도록 안전장치를 같이 마련했다.
5. 진행 중 녹화의 용량이 항상 “0 B”로 표시됨
증상
진행 중 상태 자체는 이제 정확히 잡히는데, 일부 레코드의 기록 용량이 “0 B”로 표시됐다.
원인
녹화 용량(bytes)은 3초 주기 폴링과 다운로드 진행률 파싱으로 메모리상에서는 계속 갱신되고 있었지만, 그 값을 상태 파일에 반영하는 저장 호출이 어디에도 없었다. 다른 채널의 시작/종료 이벤트가 우연히 전체 상태 저장을 트리거할 때만, 그 시점 값이 곁다리로 실려서 저장되는 식이었다 — 즉 타이밍이 맞은 레코드만 우연히 정상 용량이 찍혔다.
시도했다가 되돌린 방법
폴링 루프에서 15초 스로틀을 건 저장 헬퍼를 만들어 주기적으로 디스크에 반영하는 방식을 적용했다가, “진행 중 용량은 참고용 정보일 뿐인데 그걸 위해 상시 디스크 쓰기 부하를 늘릴 필요는 없다”고 판단해 롤백했다.
최종 해결
앱 쪽엔 실시간 갱신 부하를 추가하지 않고, 요약 메일 발송(하루 1회) 시점에만 정확한 용량을 조회하도록 방향을 바꿨다.
- 녹화 시작 시점에 저장 폴더 경로와 파일명 패턴(문자열 두 개)만 상태 파일에 기록.
- 메일 발송 스크립트가 진행 중 레코드를 만나면, 저장된 bytes 값 대신 그 순간 직접 해당 경로에서 파일을 찾아 실제 파일 크기를 계산.
for f in Path(rec_dir).glob(f"{stem}.*"):
if f.name.endswith(".ytdl") or f.name.endswith(".part"):
continue
total += f.stat().st_size
6. 컨테이너 경로와 호스트 경로가 달라서 위 방법도 무용지물이었던 케이스
증상
한 플랫폼(항목 5의 해결책 적용 후)은 정상적으로 용량이 찍혔는데, 다른 플랫폼은 여전히 0B였다.
원인
앱은 컨테이너 안에서 실행되므로 저장 경로를 컨테이너 내부 관점(/recordings/...)으로 기록한다. 그런데 요약 메일 스크립트는 컨테이너 밖 호스트에서 직접 cron으로 실행되기 때문에, 이 경로를 그대로 찾으면 존재하지 않는 경로라 실패한다. 두 플랫폼 다 같은 구조였지만, 한쪽은 다른 이벤트로 우연히 값이 저장된 경우가 있어 증상이 덜 드러났을 뿐이었다.
해결
컨테이너 볼륨 마운트 설정을 참고해서, 저장된 경로의 접두사를 실제 호스트 경로로 치환하는 함수를 추가했다.
def _to_host_path(rec_dir: str, platform: str) -> str:
host_root = RECORDINGS_ROOT_MAP.get(platform)
if not host_root or not rec_dir:
return rec_dir
if rec_dir.startswith(CONTAINER_RECORDINGS_ROOT):
return host_root + rec_dir[len(CONTAINER_RECORDINGS_ROOT):]
return rec_dir
7. 특정 플랫폼 녹화 시 완성된 파일과 별도로 미완성 임시 파일이 남음
증상
한 채널의 녹화가 끝나면 정상 완료된 원본 파일과 함께, 이름 끝에 .temp가 붙은 미완성 파일이 같은 폴더에 남았다.
원인
채널을 플랫폼 특성별로 분류하는 목록에서, 서로 다른 두 목적(“라이브 처음부터 녹화하지 않을 플랫폼” vs “오디오가 비디오보다 먼저 시작되는 캠사이트 특성을 보정할 플랫폼”)이 같은 튜플을 재사용하고 있었다.
_no_live_from_start = ("tiktok", "kick", ..., "chaturbate", ...)
_cam_platforms = _no_live_from_start # 같은 목록 재사용
이 때문에 일반 라이브 스트리밍 플랫폼인데도 캠사이트 전용 오디오 지연 보정용 ffmpeg 후처리(--postprocessor-args ...)가 불필요하게 적용되고 있었다. 이 추가 후처리 단계가 중간에 끊기면(서버 재시작 등) 임시 파일이 최종 파일로 교체되지 못한 채 남게 됐다.
해결
오디오 지연 보정이 필요 없는 플랫폼을 제외한 별도 목록을 만들어 분리했다.
_audio_delay_platforms = tuple(p for p in _cam_platforms if p != "해당플랫폼")
if platform in _audio_delay_platforms:
cmd += ["--postprocessor-args", "ffmpeg:-c:v copy -c:a aac -af adelay=1000|1000"]
포맷 셀렉터 로직 등 문제없이 잘 동작하던 다른 부분은 그대로 유지하고, 문제의 원인이었던 후처리 단계만 정확히 걷어냈다.
배운 점
- 에러 메시지 표면만 보고 외부 요인(플랫폼 정책 변경 등)을 먼저 의심하기 쉽지만, 실제로는 내부 로직의 사각지대인 경우가 많았다. 명시적으로 처리하지 않은 입력값이 조용히 기본 분기로 떨어져 그대로 전달되는 패턴은 특히 주의가 필요하다.
- “상태가 바뀌는 시점”과 “상태가 저장(영속화)되는 시점”이 분리된 구조에서는, 상태가 바뀌는 모든 경로에 실제로 저장 호출이 붙어 있는지 반드시 확인해야 한다. 저장 호출을 추가해도 저장 함수 내부의 필터 조건까지 함께 확인하지 않으면 헛수고가 될 수 있다.
- “매번 정확한 값이 필요한가, 아니면 특정 시점에만 정확하면 되는가”를 구분하면 불필요한 상시 부하를 줄일 수 있다. 실시간 폴링으로 값을 계속 갱신하는 대신, 필요한 순간(리포트 생성 시점)에만 정확한 값을 조회하는 방식이 더 적절한 경우가 있었다.
- 컨테이너 내부 경로와 호스트 경로가 다르다는, 당연하지만 놓치기 쉬운 전제를 여러 스크립트에 걸쳐 일관되게 처리하지 않으면, 겉으로는 무관해 보이는 문제(파일을 못 찾음)로 나타난다.
- 범용적으로 보이는 설정 목록을 재사용할 때는 정말 같은 대상을 가리키는지 짚고 넘어가야 한다. “이 목록도 저 목록이랑 똑같으니 재사용하자”는 접근이 당장은 편해도, 두 목적이 갈라지는 예외 케이스가 생기면 그 부작용을 한참 뒤에야 발견하게 된다.
- 로그는 사후에 필요성을 깨닫고 보강하게 되는 경우가 대부분이다. 문제를 한 번 겪고 나서 “이 값이 로그에 남아 있었다면 훨씬 빨리 끝났겠다” 싶은 지점을 로그로 남겨두면, 같은 유형의 문제가 재발했을 때 디버깅 시간을 대폭 줄일 수 있다.