유튜브 임베드를 벗어나 직접 재생하기 : 광고 없이 video 태그로 유튜브 영상 서빙하기
CEO & Fullstack Engineer
1. 임베드 플레이어로는 안 되는 것들
노래책 콘솔은 스트리머가 방송 중에 쓰는 도구입니다. 신청곡을 받고, 반주 영상을 틀고, 가사를 띄우고, 키를 조절합니다.
이 중에서 반주 영상의 상당수가 YouTube에 있습니다. 처음에는 임베드 플레이어를 붙였습니다. 가장 쉽고 정책적으로도 안전한 선택입니다.
그런데 콘솔에서 하려는 일이 임베드의 경계를 계속 넘었습니다. 결정적인 것은 키 조절이었습니다.
키 조절은 Web Audio의 phase vocoder 워크렛으로 구현합니다. 즉 <video> 요소를 AudioContext.createMediaElementSource()에 물려서 오디오 그래프를 만들어야 합니다. 그런데 이 API에는 조건이 하나 붙습니다.
오염된(tainted) 미디어 엘리먼트는
createMediaElementSource()에 넣을 수 없습니다. 일반 재생은 되더라도 오디오 그래프는 만들 수 없습니다.
iframe 안의 오디오는 애초에 만질 수 없고 크로스 오리진 미디어를 <video>에 직접 물려도 CORS 헤더가 없으면 오염된 상태가 됩니다. 그래서 프론트엔드에는 이런 좁은 허용 목록이 생겼습니다.
// CDN 배포 도메인 하나와 게이트웨이 노드 hostname 패턴만 CORS 를 보장합니다.
const CORS_MEDIA_HOSTS = new Set([MEDIA_CDN_HOST]);
const GATEWAY_HOST_PATTERN = new RegExp(
`^media-gateway(?:-[a-z0-9-]+)?\\.${SERVICE_DOMAIN_PATTERN}$`,
);그리고 <video> 태그는 이 허용 목록에 걸릴 때만 crossOrigin을 켭니다.
<video
ref={fallbackVideoRef}
src={fallbackVideoUrl}
crossOrigin={
isCorsMediaPlaybackUrl(fallbackVideoUrl) ? 'anonymous' : undefined
}
controls
playsInline
/>무조건 crossOrigin="anonymous"를 켜지 않는 이유가 있습니다. 간혹 스트리머가 저장해둔 반주 URL이 우리 도메인이 아닌 외부 링크인 경우가 있는데, 그런 응답에는 Access-Control-Allow-Origin이 없습니다. 이때 crossOrigin을 켜면 브라우저가 응답을 아예 거부해서 재생 자체가 깨집니다. 그래서 CORS를 보장하는 호스트에서만 켜고, 나머지는 예전처럼 no-CORS로 둡니다. 키 조절은 포기하되 재생은 살리는 선택입니다.
키 조절을 하려면 영상 바이트가 우리가 CORS 헤더를 통제하는 호스트에서 나와야 합니다. 그래서 영상을 우리 쪽에서 직접 다루기로 했습니다. 이 글은 그 과정에서 만든 미디어 게이트웨이의 설계 이야기입니다.
2. 첫 번째 벽: 추출한 곳과 받는 곳이 달라지면 실패합니다
영상 재생 URL을 얻는 것 자체는 어렵지 않습니다. 추출 도구가 원본 스트림 URL을 돌려줍니다.
문제는 그 URL을 다른 곳에서 쓰려고 할 때 생깁니다.
처음 구조는 자연스러운 역할 분리였습니다.
- 추출 전용 resolver 서비스가 residential proxy를 경유해 YouTube 메타데이터와 googlevideo signed URL을 추출합니다.
- 클라이언트가 그 URL을 base64로 감싸서 게이트웨이의
GET /proxy?src=...로 보냅니다. - 게이트웨이는 프록시 없이 직접 원본을 받아 클라이언트로 흘리고, 별도의
POST /warm이 오브젝트 스토리지 write-through를 담당합니다.
관심사가 분리되고 각각을 독립적으로 확장할 수 있는 깔끔한 설계로 보였습니다. 실제로는 재생이 간헐적으로 실패했습니다.
원인은 원본 스트림 URL이 추출한 주체의 출구 IP에 묶여 있다는 점이었습니다. resolver가 residential proxy IP로 추출한 URL을 게이트웨이가 다른 IP에서 요청하면 403이 돌아옵니다. 추출하는 IP와 받아오는 IP가 같아야 한다는 뜻입니다.
같은 영상을 게이트웨이 IP에서 직접 추출하고 직접 받았을 때는 전부 200 또는 206이 나왔습니다. 문제는 코드가 아니라 위상이었습니다.
그래서 역할 분리를 되돌렸습니다. resolver 호출 경로, /proxy, /warm을 모두 폐기하고 /play 하나로 합쳤습니다. 이때 638줄이 지워졌고, 설정에서는 resolver_url과 /warm 전용 관리자 시크릿인 gateway_admin_secret이 함께 사라졌습니다.
지금은 추출과 원본 다운로드가 의도적으로 같은 게이트웨이 노드에서 일어납니다. 관심사 분리보다 출구 IP 일치가 우선이라는 뜻입니다.
한 번 겪은 실패 모드는 코드에서 지우는 것만으로는 부족합니다. 다음 사람이 “이거 왜 한 노드에서 다 하지, 분리하면 되잖아”라고 생각하는 것까지 막아야 같은 사고가 반복되지 않습니다.
덤으로 얻은 것도 있습니다. 재생 트래픽이 residential proxy를 타지 않게 되면서 그 비용이 0이 됐고, 중간에 squid 같은 별도 인프라를 둘 이유도 없어졌습니다.
3. 두 번째 벽: 코드가 아니라 IP가 죽습니다
역할 분리를 되돌린 뒤에도 재생이 또 죽었습니다. 이번에는 성격이 달랐습니다.
어느 날 스트리머가 신청한 미캐시 영상 하나가 재생되지 않는다는 제보가 들어왔습니다. 진단해보니 프로덕션 노드 두 대의 출구 IP 양쪽에서 yt-dlp가 어떤 player client로 시도해도 같은 응답을 받았습니다.
ERROR: Sign in to confirm you're not a bot같은 시점에 다른 출구 IP를 쓰는 QA 게이트웨이는 멀쩡했습니다. 코드도 같고 yt-dlp 버전도 같은데 IP만 다른 노드끼리 결과가 갈렸습니다. 이렇게 갈릴 때 다음에 확인할 것은 스택 트레이스가 아니라 출구 IP입니다.
원인 추정은 이렇습니다. 직전에 캐시를 한 번에 채우려고 36시간짜리 빅뱅 워밍을 돌렸고, 그 사이 약 3만 건의 호출이 두 IP에 집중됐습니다. Google 입장에서는 명확한 abuse 패턴이었고, 두 IP는 그대로 봇 챌린지 대상이 됐습니다.
이 사고에서 가장 중요한 관찰은 영향 범위가 아니라 관측의 사각지대였습니다.
- 이미 캐시된 9,101개 영상은 영향이 0이었습니다. 오브젝트 스토리지에서 바로 나가는 경로였기 때문입니다.
- 미캐시 영상만 실패했습니다.
- 그런데
/healthz는 계속 200이었습니다. 프로세스 자체는 멀쩡히 살아 있었기 때문입니다.
즉 헬스체크가 전부 초록인 상태로 신규 재생만 전멸했습니다. /healthz가 증명하는 것은 caddy와 FastAPI 프로세스가 살아 있다는 사실뿐입니다. 이 경험이 나중에 gateway_play_extract_total이라는 지표와 그 지표를 보는 알럿으로 이어집니다.
당장의 대응은 새 IP였습니다. VPS 사업자에게 노드 다섯 대를 새로 발급받아 옮겼습니다. 하지만 이건 근본 해결이 아니라 시간 벌기입니다. IP를 바꾸는 것으로 해결되는 문제는 IP를 다시 태우면 다시 발생합니다.
그래서 방향을 두 가지로 잡았습니다. 하나는 워밍을 사용자 재생 경로와 분리하고 동시성을 낮추는 것, 다른 하나는 출구 IP의 성격 자체를 바꾸는 것입니다.
후자에서 알게 된 사실이 이 시스템이 온프렘으로 간 진짜 이유입니다. AWS 데이터센터 IP는 애초에 선택지가 아니었습니다.
한국 UGC 노래방 영상 대부분이 AWS 대역에서 막혀 있었습니다. 클라우드에서 도는 워크로드가 클라우드 밖으로 나가야 하는 이유가 성능도 비용도 아니고 출구 IP의 평판이었습니다. 결국 게이트웨이는 AWS에서 VPS로, VPS에서 자체 온프렘 k3s 클러스터로 이동해 정착했습니다.
4. 전체 흐름
최종 구조는 이렇습니다.
핵심은 캐시 히트와 미스가 서로 다른 경로를 탄다는 점입니다.
한 번 받아둔 영상은 게이트웨이를 거치지 않습니다. 백엔드가 CloudFront 서명 URL을 발급하고 브라우저는 AWS의 엣지에서 직접 받습니다. 게이트웨이는 처음 한 번, 아직 저장되지 않은 영상에만 손을 댑니다.
이 분리가 중요한 이유는 자원의 성격이 다르기 때문입니다. 추출은 무겁고 실패할 수 있고 IP 평판에 의존하는 작업입니다. 이미 저장된 파일을 전달하는 것은 가볍고 결정적입니다. 두 가지를 같은 경로에 두면 무거운 쪽의 장애가 가벼운 쪽까지 끌고 내려갑니다. 앞 장의 사고에서 캐시된 9,101개가 멀쩡했던 것이 정확히 이 분리 덕분입니다.
비용 측면에서도 이 분리가 유리합니다. 실측 기준 미디어 서빙은 월 5,000GB를 넘는데 CloudFront 약정 단가는 GB당 0.02달러이고 온프렘에서 터널로 같은 양을 내보내면 GB당 0.10달러 수준이 됩니다. compute를 온프렘으로 내리면서도 스토리지와 CDN 레이어는 AWS에 남긴 이유입니다.
5. 노드 링의 실제 모습
프로덕션은 온프렘 k3s의 프로덕션 네임스페이스에서 물리 노드 3대에 worker를 하나씩, 각각 1 replica로 돌립니다. HPA도, PodDisruptionBudget도, anti-affinity도 전부 꺼져 있습니다. 이 워크로드에서 중요한 것은 replica 수가 아니라 어느 물리 노드에서 나가는가이기 때문입니다.
worker A, B, C는 각각 별도의 Helm 릴리스로 배포하고 파드에는 worker: a / worker: b / worker: c 라벨을 붙입니다. nodeSelector로 A는 물리 노드 1, B는 물리 노드 2, C는 물리 노드 3에 고정합니다. 그리고 worker마다 공개 hostname이 서로 다르게 하나씩 붙습니다. 백엔드가 해시로 특정 노드를 지목하려면 노드마다 부를 수 있는 주소가 따로 있어야 하기 때문입니다.
여기에 노드에 묶이지 않는 hostname이 두 개 더 있습니다. 하나는 fleet 엔드포인트이고 다른 하나는 VPS 시절에 발급된 서명 URL을 계속 받아주기 위한 호환 hostname입니다. 이 둘은 개별 worker Service가 아니라 fleet Service로 갑니다.
apiVersion: v1
kind: Service
metadata:
name: media-gateway-fleet
spec:
type: ClusterIP
selector:
media-gateway/fleet: prod셀렉터가 worker 라벨이 아니라 fleet 라벨이라서 A/B/C 파드 전부를 잡습니다. 개별 노드가 하나 죽어도 fleet 엔드포인트는 살아 있습니다.
외부 노출은 전부 Cloudflare Tunnel입니다. hostname 다섯 개 모두 같은 터널을 향하는 프록시된 CNAME이고 터널 쪽 ingress 매핑이 hostname을 각각 다른 Service로 갈라줍니다. fleet 엔드포인트와 호환 hostname 둘은 fleet Service로 보내고, 노드별 hostname 셋은 각각의 worker Service로 보냅니다. 공개 주소는 다섯 개지만 터널은 하나이고 어느 파드까지 내려갈지는 터널 설정이 정합니다.
QA는 훨씬 단순합니다. QA 네임스페이스에 1 replica, 주소도 QA 게이트웨이 hostname 하나뿐입니다.
자원 한도는 프로덕션이 요청 250m CPU / 384Mi, 상한 2 CPU / 1Gi입니다. QA는 요청 100m / 256Mi, 상한 1 CPU / 1Gi입니다. 추출이 CPU를 오래 쓰는 일이 아니라 짧고 굵게 쓰는 일이라서 상한만 넉넉히 잡아뒀습니다.
6. 일관 해시로 singleflight를 살립니다
게이트웨이는 여러 노드로 운영합니다. 그러면 요청을 어떻게 분배할지가 문제가 됩니다.
일반적인 답은 라운드로빈이나 최소 연결입니다. 여기서는 그렇게 하면 안 됐습니다.
같은 영상에 대한 동시 요청은 한 번만 추출해야 합니다. 게이트웨이는 프로세스 단위로 singleflight 중복 제거를 겁니다. 그런데 요청이 노드마다 흩어지면 각 노드가 자기 몫의 중복 제거만 하게 되어 결국 노드 수만큼 중복 추출이 일어납니다. 앞 장에서 본 것처럼 중복 추출은 단순한 낭비가 아니라 IP를 태우는 행위입니다.
대신 백엔드가 영상 ID를 해시해서 노드를 고릅니다.
private selectGatewayNodeUrl(videoId: string): string {
if (this.nodeUrls.length === 0) return this.gatewayBaseUrl;
const firstByte = crypto.createHash('sha256').update(videoId).digest()[0];
return this.nodeUrls[firstByte % this.nodeUrls.length];
}sha256의 첫 바이트를 노드 수로 나눈 나머지입니다. 화려한 것은 없지만 필요한 성질은 충족합니다. 같은 영상은 항상 같은 노드로 갑니다.
게이트웨이 쪽 singleflight는 이렇게 생겼습니다.
class _Singleflight:
"""같은 key 의 동시 호출을 한 번의 실제 fn 실행으로 묶는다."""
def __init__(self) -> None:
self._inflight: dict[str, asyncio.Future[ExtractedFormat]] = {}
async def do(self, key: str, fn) -> ExtractedFormat:
existing = self._inflight.get(key)
if existing is not None:
return await existing
loop = asyncio.get_running_loop()
fut: asyncio.Future[ExtractedFormat] = loop.create_future()
self._inflight[key] = fut
try:
result = await fn()
except BaseException as exc:
if not fut.done():
fut.set_exception(exc)
raise
else:
if not fut.done():
fut.set_result(result)
return result
finally:
self._inflight.pop(key, None)여기에 세마포어가 하나 더 겹칩니다. play_extract_concurrency 기본값은 1입니다. 즉 한 프로세스에서 동시에 도는 yt-dlp 추출은 한 건이고 같은 video_id에 대한 요청은 그 한 건을 공유합니다. 추출 자체에는 20초 하드 타임아웃이 걸립니다.
async def _do() -> ExtractedFormat:
async with self._semaphore:
return await asyncio.wait_for(
asyncio.to_thread(self._extract_sync, video_id),
timeout=self._timeout_seconds,
)다만 구멍이 하나 남습니다. 컨테이너는 gunicorn -w 2로 뜨기 때문에 파드 하나에 워커 프로세스가 둘이고, singleflight와 세마포어는 프로세스 단위입니다. 따라서 같은 영상이 같은 파드의 서로 다른 워커에 떨어지면 추출이 최대 두 번 일어납니다. 노드 수만큼(3배)이 아니라 워커 수만큼(2배)으로 줄인 것이지 완전히 없앤 것은 아닙니다. 완전히 없애려면 공유 저장소 기반 락이 필요한데, 지금 트래픽 규모에서는 그 복잡도를 살 이유가 없어서 상한만 낮추고 남겨뒀습니다.
캐시 히트 경로에도 같은 종류의 샤딩이 있는데 이유는 전혀 다릅니다.
private selectCachedGatewayNodeUrl(key: string): string {
if (this.cachedNodeUrls.length === 0) return this.gatewayBaseUrl;
const firstByte = crypto.createHash('sha256').update(key).digest()[0];
return this.cachedNodeUrls[firstByte % this.cachedNodeUrls.length];
}여기서 해시 대상은 video_id가 아니라 캐시 키입니다. 목적은 중복 제거가 아니라 트래픽 쏠림 방지입니다.
// Cache-hit gateway URLs. The backend shards by R2 key so persistent
// Cloudflare Tunnel connections cannot pin all traffic to one on-prem pod.
MEDIA_GATEWAY_CACHED_NODE_URLS?: string;Cloudflare Tunnel은 연결을 오래 유지합니다. hostname이 하나면 그 연결이 특정 파드에 고정되어 모든 트래픽이 한 노드로 몰릴 수 있습니다. hostname 자체를 여러 개로 나누고 백엔드가 발급 단계에서 뿌리면 이 고정이 풀립니다.
부하 분산의 기본값이 항상 옳지는 않습니다. 노드가 상태를 들고 있으면 요청을 고르게 뿌리는 것이 그 상태를 무력화하고 반대로 연결이 오래 유지되는 경로에서는 발급 단계에서 명시적으로 흩어줘야 합니다.
7. /play 콜드 미스의 다섯 단계
/play는 이 시스템에서 유일하게 무거운 엔드포인트입니다. 받는 파라미터는 vid, exp, sig 셋뿐이고 임의의 원본 URL은 절대 받지 않습니다. /proxy 시절에 base64로 감싼 임의 URL을 받던 것을 의도적으로 없앤 결과입니다.
흐름은 다섯 단계입니다.
1단계. 검증. video_id를 ^[A-Za-z0-9_-]{11}$ 정규식으로 확인하고 스토리지 설정이 비어 있으면 503을 돌려주고, HMAC 서명을 검증합니다. 재생목록이나 임의 URL이 여기서 전부 걸립니다.
2단계. 추출하기 전에 S3를 먼저 봅니다. 이 단계가 이 엔드포인트에서 가장 실용적인 대목입니다.
head_meta = await _asyncio.to_thread(
head_object, _storage_client, settings.s3_bucket, storage_key
)
if head_meta is not None:
# callback 으로 backend 인덱스 self-heal (best-effort, latency 영향 0).
_asyncio.create_task(
_send_cache_upsert_callback(vid, storage_key, head_meta)
)
presigned = generate_presigned_url(
_storage_client, settings.s3_bucket, storage_key, expires_in=3600
)
return await proxy_stream(...)백엔드가 “미스”라고 판단해서 보냈는데 S3에는 파일이 있는 경우가 실제로 생깁니다. 예전 콜백이 유실됐거나, 인덱스 마이그레이션이 덜 됐거나, 다른 노드가 방금 채운 경우입니다. 이때 yt-dlp를 부르는 것은 순수한 낭비이자 IP 소모입니다. 그래서 HEAD를 먼저 던집니다. 파일이 있으면 추출을 건너뛰고, 콜백을 쏴서 백엔드 인덱스를 스스로 고칩니다. 콜백은 fire-and-forget이라 응답 지연에 영향을 주지 않습니다.
인덱스 복구를 별도 배치 작업으로 빼지 않고 정상 경로에 얹었습니다. 미스가 한 번 날 때마다 인덱스가 한 칸씩 정확해집니다.
3단계. 그래도 없으면 직접 추출합니다. yt-dlp 옵션은 의도적으로 좁습니다.
opts: dict[str, object] = {
"quiet": True,
"no_warnings": True,
"skip_download": True,
"noplaylist": True,
# progressive (videos+audio) only
"format": "18/best[ext=mp4][vcodec!=none][acodec!=none]/best[vcodec!=none][acodec!=none]",
"youtube_include_dash_manifest": False,
"youtube_include_hls_manifest": False,
}DASH와 HLS 매니페스트를 끄고 muxed/progressive 포맷만 받습니다. 영상과 오디오가 분리된 스트림을 받으면 ffmpeg 병합이 필요한데, 이 노드에서 그런 작업을 돌리고 싶지 않았습니다. 캐시 키가 itag18.mp4로 고정된 것도 여기서 나옵니다.
로깅 정책도 명시적입니다. yt-dlp에 별도 로거 핸들러를 붙이지 않습니다. 로거를 가로채면 yt-dlp가 서명된 URL을 그대로 뱉을 위험이 있어서, 위의 quiet와 no_warnings로 stdout 자체를 막는 쪽을 택했습니다.
서명된 googlevideo URL은 그 자체가 임시 자격증명입니다. 로그에 남기면 로그 수집 파이프라인 전체가 자격증명 저장소가 됩니다. 그래서 로그에는 video_id, format_id, 만료 시각만 남깁니다.
4단계. 백그라운드로 S3에 씁니다. 여기서 지킨 원칙은 “완전히 성공했을 때만 콜백”입니다.
async def _ingest_and_callback(video_id, storage_key, src_url) -> None:
"""배경 task: S3 write-through 후 S3 write 완료 시에만 callback."""
try:
meta = await ingest_to_s3(...)
await _send_cache_upsert_callback(video_id, storage_key, meta)
except Exception:
# partial download / S3 write 실패 → callback 안 보냄 (partial cache 방지).
logger.exception("play_background_ingest_failed", extra={"video_id": video_id})부분 다운로드를 캐시로 등록하면 그 영상은 영원히 깨진 채로 히트합니다. 재생이 안 되는데 캐시 히트라서 게이트웨이도 안 거치는, 가장 고치기 어려운 상태가 됩니다. 그래서 업로드 함수 안에서도 크기를 검사합니다.
if expected_size is not None and total_size != expected_size:
raise RuntimeError(
f"partial_download size={total_size} expected={expected_size}"
)
if total_size == 0:
raise RuntimeError("empty_body")메모리도 신경 씁니다. SpooledTemporaryFile로 5MB까지만 RAM에 두고 넘으면 디스크로 흘립니다. 동시에 여러 건이 들어와도 파드 메모리가 터지지 않게 하는 장치입니다.
5단계. 클라이언트에는 곧바로 원본을 흘려줍니다. S3 쓰기가 끝날 때까지 기다리지 않습니다. 방송 중에 다음 곡으로 넘어가는 순간의 공백이 그대로 방송 사고처럼 보이기 때문에, 첫 재생 지연을 줄이는 것이 이 경로의 유일한 목표입니다. 저장은 뒤에서 따라옵니다.
스트리밍 프록시에도 두 가지 방어가 들어 있습니다. 하나는 전송 오류나 502/503/504가 오면 0.4초 뒤 한 번 재시도하는 것이고, 다른 하나는 응답에 반드시 no-store를 붙이는 것입니다.
# Headers we always add on proxy responses to prevent any intermediate cache
# (browser, Caddy, CDN) from reusing a signed URL response past the signature's
# exp window.
_NO_STORE_HEADERS = {
"Cache-Control": "no-store",
"Pragma": "no-cache",
}서명 URL로 받은 응답을 중간 캐시가 재사용하면 만료 개념이 무너집니다. 만료가 있는 응답은 캐시되면 안 됩니다.
8. 서명 URL의 두 가지 종류
이 시스템에는 서명된 URL이 두 종류 있고 각각 다른 이유로 서명합니다.
HMAC: 게이트웨이에 일을 시킬 권한
아무나 게이트웨이에 추출을 시킬 수 없어야 합니다. 백엔드와 게이트웨이가 공유 비밀을 갖고, 요청을 정규 문자열로 만든 뒤 서명합니다. 백엔드 쪽 구현입니다.
static buildCanonical(
method: string,
path: string,
params: Record<string, string>,
): string {
const parts = [`method=${method.toUpperCase()}`, `path=${path}`];
for (const key of Object.keys(params).sort()) {
parts.push(`${key}=${params[key]}`);
}
return parts.join('&');
}게이트웨이 쪽은 같은 문자열을 파이썬으로 만듭니다.
def _canonical(method: str, path: str, params: dict[str, str]) -> str:
parts = [f"method={method.upper()}", f"path={path}"]
for k in sorted(params):
parts.append(f"{k}={params[k]}")
return "&".join(parts)/play 요청 하나의 정규 문자열은 이렇게 나옵니다.
method=GET&path=/play&exp=1234567890&vid=abc12345678파라미터를 정렬하는 것이 요점입니다. 쿼리스트링 순서가 달라져도 같은 서명이 나와야 검증이 안정적입니다. 검증 쪽에는 실무적인 장치가 몇 개 더 붙어 있습니다.
- 서명 형식을
^[0-9a-f]{64}$로 먼저 확인해서 이상한 입력에 시간을 쓰지 않습니다. - 만료 비교에 10초 시계 오차를 허용합니다.
- 비교는
hmac.compare_digest로 합니다. - 이전 시크릿을 함께 검증합니다. 시크릿 교체 중에 발급된 URL이 죽지 않도록 하는 겹침 구간용이고 통과하면
ok_prev라는 별도 사유로 기록됩니다.
실패 사유는 HTTP 상태에도 반영됩니다. 만료는 410, 서명 불일치와 형식 오류는 401, 나머지는 400입니다. “만료됐다”와 “위조됐다”는 운영상 완전히 다른 사건이라서 응답에서부터 구분해둡니다.
TTL 설정에서는 실제로 한 번 데였습니다.
// Kubernetes env values are always strings even though env.config parses
// this field for the typed config object. ConfigService may still resolve
// the raw process.env value first, so normalize it at the use site too.
this.ttlSeconds = Number(
this.configService.get<number | string>(
'MEDIA_GATEWAY_URL_TTL_SECONDS',
) ?? 1800,
);기본값은 1800초이고 환경 설정 파서가 60에서 21600 사이인지 검증합니다. 문제는 Kubernetes 환경변수가 언제나 문자열이라는 점이었습니다. ConfigService가 타입 변환된 값이 아니라 원시 process.env 값을 먼저 돌려주는 경로가 있어서, Math.floor(Date.now() / 1000) + this.ttlSeconds가 숫자 덧셈이 아니라 문자열 연결이 됐습니다. 결과적으로 만료 시각이 터무니없는 값이 되고 CloudFront 정책에는 숫자여야 할 자리에 문자열이 들어갔습니다.
타입 선언이 number라고 해서 런타임 값이 숫자인 것은 아닙니다. 경계를 넘어온 값은 쓰는 자리에서 한 번 더 정규화하는 편이 안전합니다. 이 수정에는 만료 시각이 실제로 21600초 근처인지 확인하는 테스트가 함께 들어갔습니다.
RSA: CDN에서 파일을 받을 권한
캐시 히트는 게이트웨이를 거치지 않으므로 검증 주체가 CloudFront입니다. 그래서 방식이 완전히 다릅니다. 만료 시각을 담은 정책을 개인 키로 서명하고 CloudFront가 키 그룹에 등록된 공개 키로 검증합니다.
private signCloudFrontUrl(key: string): ResolvedPlayback {
const exp = Math.floor(Date.now() / 1000) + this.ttlSeconds;
const encodedKey = key
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
const resourceUrl = `${this.mediaCdnBaseUrl}/${encodedKey}`;
const policy = JSON.stringify({
Statement: [
{
Resource: resourceUrl,
Condition: {
DateLessThan: { 'AWS:EpochTime': exp },
},
},
],
});
const signature = crypto
.sign('RSA-SHA256', Buffer.from(policy, 'utf8'), this.mediaCdnPrivateKey)
.toString('base64')
.replace(/\+/g, '-')
.replace(/=/g, '_')
.replace(/\//g, '~');
const query = [
`Expires=${exp}`,
`Signature=${signature}`,
`Key-Pair-Id=${encodeURIComponent(this.mediaCdnKeyPairId)}`,
'Hash-Algorithm=SHA256',
].join('&');
return {
playbackUrl: `${resourceUrl}?${query}`,
expiresAt: new Date(exp * 1000).toISOString(),
source: 'cached',
};
}주의할 지점이 세 군데 있습니다.
첫째, base64 치환 규칙이 표준 URL-safe base64와 다릅니다. CloudFront는 +를 -로, =를 _로, /를 ~로 바꾼 변형을 요구합니다. 일반적인 base64url(+는 -, /는 _)과 헷갈리기 쉬운 부분입니다.
둘째, 키 경로를 세그먼트 단위로 인코딩하고 슬래시는 보존합니다. 슬래시까지 인코딩하면 리소스 경로가 달라져 서명이 안 맞습니다.
셋째, Key-Pair-Id가 어느 공개 키인지를 CloudFront에 알려줍니다. 이 값은 인프라 코드에서 만든 공개 키 리소스의 ID이고 개인 키는 시크릿 저장소에만 있습니다.
resource "aws_cloudfront_public_key" "cache" {
name = "media-cache-signing-${var.key_revision}"
comment = "Public key for backend-issued media cache signed URLs"
encoded_key = var.cloudfront_public_key_pem
}
resource "aws_cloudfront_key_group" "cache" {
name = "media-cache-signing"
items = [aws_cloudfront_public_key.cache.id]
}키 이름에 회전 리비전을 넣어둔 것은 교체 대비입니다. 키 그룹에 새 공개 키를 추가하고 백엔드 개인 키를 바꾼 뒤 옛 키를 빼면 무중단으로 돌릴 수 있습니다.
검증은 테스트에서 실제로 합니다. 테스트가 임시 RSA 키 쌍을 만들어 URL을 발급하고 쿼리스트링에서 서명을 꺼내 치환을 되돌린 뒤 공개 키로 crypto.verify를 통과시킵니다. 서명 로직은 눈으로 봐서는 맞는지 알 수 없으니 왕복 검증을 붙여두는 편이 낫습니다.
9. 폴백은 켜지지 않는 것을 확인하고 둡니다
CDN 서명에는 개인 키가 필요합니다. 이 키가 없거나 형식이 깨져 있으면 서명이 실패합니다.
여기서 흔한 실수는 서명 시점에 터지도록 두는 것입니다. 그러면 키 설정이 잘못된 순간부터 모든 캐시 히트가 500으로 죽습니다. 게다가 이 값은 PEM이라 줄바꿈이 들어 있고 환경변수로 옮기는 과정에서 \n이 문자 그대로 들어오는 사고가 잦습니다.
그래서 생성자에서 두 가지를 합니다. 문자열 \n을 실제 줄바꿈으로 되돌리고, 키를 한 번 파싱해본 뒤 사용 가능 여부를 확정합니다.
this.mediaCdnPrivateKey = (
this.configService.get<string>('MEDIA_CDN_PRIVATE_KEY') ?? ''
).replace(/\\n/g, '\n');
this.mediaCdnEnabled = false;
if (
this.mediaCdnBaseUrl &&
this.mediaCdnKeyPairId &&
this.mediaCdnPrivateKey
) {
try {
crypto.createPrivateKey(this.mediaCdnPrivateKey);
this.mediaCdnEnabled = true;
} catch {
this.logger.error(
'MEDIA_CDN_PRIVATE_KEY is invalid; cache hits will use the gateway fallback.',
);
}
}세 값이 모두 있고 키가 실제로 파싱되는 경우에만 mediaCdnEnabled가 true가 됩니다. 그러지 않으면 캐시 히트도 게이트웨이의 인증된 전달 경로로 내려갑니다.
private signCachedUrl(key: string): ResolvedPlayback {
if (this.mediaCdnEnabled) {
try {
return this.signCloudFrontUrl(key);
} catch {
this.logger.error(
'CloudFront URL signing failed; cache hit will use the gateway fallback.',
);
}
}
// Migration/rollback fallback: retain the previous signed gateway path
// until the CloudFront key group and backend secret are both available.
const baseUrl = this.selectCachedGatewayNodeUrl(key);
...
}느리지만 재생은 됩니다. /cached 엔드포인트가 남아 있는 이유가 이것입니다. 정상 경로에서는 쓰이지 않고 마이그레이션과 롤백을 위해 존재합니다.
/cached 자체도 바이트를 통과시키지 않으려고 합니다. 인증이 끝나면 S3 presigned URL로 307 리다이렉트를 보냅니다.
def _select_cached_delivery(
mode: str,
origin: str | None,
storage_cors_origins: list[str],
) -> tuple[str, str]:
if mode == "proxy":
return "proxy", "configured"
if origin and origin not in storage_cors_origins:
return "proxy", "origin_not_storage_cors"
return "redirect", "direct"브라우저 오리진이 S3 CORS 허용 목록에 없을 때만 게이트웨이가 직접 릴레이합니다. 프리뷰 배포처럼 열거할 수 없는 오리진을 살려두면서, 일반 트래픽은 온프렘 대역폭을 쓰지 않게 하는 절충입니다. 리다이렉트 응답에는 Cache-Control: private, no-store와 Referrer-Policy: no-referrer를 붙입니다. presigned URL이 리퍼러로 새어나가지 않게 하기 위해서입니다.
여기서 스스로 경계한 것이 하나 있습니다. 이 폴백은 에러를 감추는 장치가 되기 쉽습니다. 조용히 느린 경로로 넘어가면 아무도 키가 깨진 걸 모릅니다.
그래서 두 가지를 함께 걸었습니다. 폴백으로 넘어갈 때 에러 로그를 남기고, 게이트웨이 쪽에 전달 카운터를 둡니다.
CACHED_DELIVERY = Counter(
"gateway_cached_delivery_total",
"Authenticated /cached requests by delivery mode and selection reason.",
labelnames=("mode", "reason"),
)mode와 reason이 함께 붙어 있어서 왜 그 경로를 탔는지가 지표에 그대로 남습니다. 폴백은 사용자에게 서비스를 유지해주는 장치이지, 운영자에게 문제를 숨기는 장치가 아니어야 합니다.
같은 이유로 캐시 히트 시각 기록은 반대로 처리합니다.
void this.prisma.videoCacheEntry
.update({ where: { videoId }, data: { lastHitAt: new Date() } })
.catch(() => {
/* lastHitAt 업데이트는 best-effort */
});이건 통계용 필드입니다. 여기서 실패했다고 재생을 막을 이유가 없으므로 의도적으로 결과를 기다리지 않습니다. 무엇이 실패해도 되는 작업이고 무엇이 아닌지를 코드에서 구분하는 것이 중요합니다.
한 가지 더, 임시 조치에는 만료를 박아뒀습니다. 서명 도입 전환기에 서명 없는 요청을 통과시키는 스위치가 있었는데 그 스위치를 켜둔 채 기한이 지나면 프로세스가 아예 기동하지 않습니다.
if now >= deadline:
raise RuntimeError(
f"GATEWAY_ALLOW_UNSIGNED_UNTIL ({raw}) has passed; "
"refusing to start with the unsigned bypass still enabled. "
"Remove the env var or update the deadline."
)이 검사는 모듈 임포트 시점에 한 번 실행됩니다. 전환기용 우회로는 남겨두면 반드시 영구 설정이 됩니다. 스스로 만료되게 만들어야 실제로 사라집니다.
10. 캐시 키를 영상 단위로 바꾼 이유
초기에는 캐시 키를 Song 행에 컬럼으로 달아뒀습니다. 자연스러운 모델링처럼 보였습니다. 곡이 있고 곡에 반주 영상이 붙습니다.
그런데 실제 데이터에서는 같은 영상이 여러 채널의 여러 곡에 붙습니다. 인기 있는 반주 영상은 수십 개 채널의 노래책에 동시에 등록됩니다. 곡 단위로 캐시를 관리하면 다음 문제가 생깁니다.
- 같은 영상이 N개 행에서 각각 다른 캐시 키를 갖습니다. 실제 오브젝트는 하나인데 인덱스는 N개입니다.
- 새 채널이 그 영상을 등록하면 캐시 키가 비어 있어서 미스가 납니다. 이미 저장돼 있는데도 다시 추출합니다.
- 워밍을 한 번 돌려도 그 뒤에 추가되는 행은 영구 미캐시 상태가 됩니다.
프로덕션에서 캐시 키가 비어 있는 행이 1만 4천 개였습니다. 워밍을 아무리 돌려도 줄지 않는 종류의 숫자입니다.
캐시를 별도 테이블로 정규화했습니다.
model VideoCacheEntry {
videoId String @id @map("video_id") @db.VarChar(20)
r2Key String @map("r2_key") @db.VarChar(200)
itag String @default("18") @db.VarChar(20)
source String @default("youtube") @db.VarChar(20)
cachedAt DateTime @default(now()) @map("cached_at") @db.DateTime(0)
lastHitAt DateTime? @map("last_hit_at") @db.DateTime(0)
@@map("video_cache_entries")
}video_id가 기본 키입니다. 채널과 곡이 무엇이든 같은 영상은 한 번만 저장하고 한 번만 추출합니다. 기존 데이터는 distinct video_id로 접어서 9,101건이 이관됐습니다.
캐시 키 생성은 양쪽에서 같은 규칙을 씁니다.
export function buildR2CacheKey(videoId: string): string {
if (!VIDEO_ID_RE.test(videoId)) {
throw new Error(`invalid videoId: ${videoId}`);
}
return `youtube/${videoId}/itag18.mp4`;
}def _build_cache_key(video_id: str) -> str:
return f"youtube/{video_id}/itag18.mp4"이 변경이 남긴 흔적이 두 가지 있습니다.
첫째, 쓰이지 않는 파라미터가 남았습니다.
async createPlaybackUrl(
videoUrl: string,
_songId?: number,
): Promise<ResolvedPlayback> {밑줄이 붙은 _songId는 조회에 전혀 쓰이지 않습니다. 같은 video_id는 채널이나 Song 행과 무관하게 한 번만 캐시되므로 조회에 쓸 이유가 없어졌고, 호출 시그니처만 호환을 위해 남겨뒀습니다.
프론트엔드는 아직 요청 본문에 songId를 실어 보냅니다. 서버가 무시할 뿐입니다. 한 번에 지우려면 백엔드, 프론트엔드, DTO를 동시에 건드려야 해서 단계를 나눴고, 그 중간 상태가 코드에 밑줄 하나로 남았습니다. 아직 정리되지 않은 부채입니다.
둘째, 필드 이름이 사실과 어긋납니다. r2Key와 r2_key는 Cloudflare R2를 쓰던 시절의 이름입니다. 지금 값은 S3 키인데 이름은 그대로입니다. 게이트웨이가 백엔드로 보내는 콜백도 같은 필드명을 그대로 씁니다.
body = {
"videoId": video_id,
# The callback field remains r2Key for the existing database/API
# contract; its value is now an object-storage-neutral cache key.
"r2Key": storage_key,
...
}이름을 바꾸려면 게이트웨이, 백엔드, 그리고 이 캐시를 함께 쓰는 다른 서비스의 API와 DB를 동시에 옮겨야 합니다. 이름이 틀린 것은 불편이고 계약이 깨지는 것은 장애입니다. 그래서 이름은 남기고 값의 의미만 중립적으로 바꾼 뒤 별도 마이그레이션 항목으로 뒀습니다.
캐시의 단위는 그것을 참조하는 도메인 객체가 아니라 실제로 동일한 바이트가 되는 단위로 잡아야 합니다. 곡이 아니라 영상이 캐시의 단위였습니다.
11. 저장과 전달: S3와 CloudFront
프로덕션과 QA가 같은 비공개 미디어 캐시 버킷을 씁니다. 환경을 나누지 않은 이유는 단순합니다. 같은 영상의 같은 바이트라서 QA가 채운 것을 프로덕션이 그대로 쓰면 추출 횟수가 줄어듭니다. 추출 횟수를 줄이는 것이 곧 IP를 지키는 일이니 자연스러운 선택입니다.
업로드 시 붙는 메타데이터가 두 개 있습니다.
ExtraArgs={
"ContentType": content_type,
"StorageClass": "INTELLIGENT_TIERING",
"CacheControl": "public, max-age=31536000, immutable",
},INTELLIGENT_TIERING을 쓰는 이유는 접근 패턴이 극단적으로 갈리기 때문입니다. 유행하는 반주 영상은 매일 재생되고 어떤 영상은 한 번 캐시된 뒤 몇 달간 아무도 찾지 않습니다. 어느 쪽인지 미리 알 수 없으니 티어링을 AWS에 맡깁니다. lastHitAt 필드를 굳이 남기는 것도 나중에 이 패턴을 확인하기 위해서입니다.
immutable을 붙일 수 있는 이유는 키 자체가 내용을 결정하기 때문입니다. youtube/{videoId}/itag18.mp4는 같은 키에 다른 바이트가 오지 않습니다.
CORS는 두 계층에 각각 있습니다. S3 버킷 정책과 CloudFront 응답 헤더 정책이 같은 허용 오리진 목록을 씁니다.
allowed_origins = var.service_origins # 서비스 도메인과 그 서브도메인만
allowed_methods = ["GET", "HEAD"]
allowed_headers = ["Range"]
expose_headers = [
"Content-Length",
"Content-Range",
"Accept-Ranges",
"Content-Type",
"ETag",
]
max_age_seconds = 86400Range를 허용하고 Content-Range와 Accept-Ranges를 노출하는 것이 핵심입니다. 사용자가 재생 중에 탐색하면 브라우저가 범위 요청을 보내는데 이 헤더가 노출되지 않으면 탐색이 깨집니다. 1장에서 본 Web Audio 요구사항과 이 목록이 정확히 이어집니다.
캐시 정책은 서명 파라미터를 캐시 키에서 의도적으로 제외합니다.
resource "aws_cloudfront_cache_policy" "media" {
name = "media-immutable-1y"
default_ttl = 31536000
max_ttl = 31536000
min_ttl = 86400
parameters_in_cache_key_and_forwarded_to_origin {
cookies_config { cookie_behavior = "none" }
headers_config { header_behavior = "none" }
query_strings_config { query_string_behavior = "none" }
}
}오브젝트 키가 사실상 불변이라서 가능한 설정입니다. 서명 파라미터를 캐시 키에 넣으면 뷰어마다, 발급 시각마다 다른 캐시 엔트리가 생겨서 캐시가 사실상 작동하지 않습니다. 서명은 접근 제어의 문제이고 캐시 키는 동일성의 문제인데, 이 둘을 섞으면 둘 다 망가집니다.
권한 분리도 명확합니다.
- CloudFront는 OAC(Origin Access Control)로 S3에 접근합니다. 버킷 정책은 이 배포판 ARN에서 오는 요청에만
s3:GetObject를 허용합니다. - 온프렘 게이트웨이는 AWS 밖에 있으므로 IAM 역할을 쓸 수 없습니다. HEAD와 PUT, 그리고 비상용 프록시 GET에만 쓰는 좁게 제한된 정적 자격증명을 씁니다. 뷰어에게 파일이 전달되는 경로는 이 자격증명을 전혀 쓰지 않습니다.
버킷은 퍼블릭 액세스 차단 4종이 전부 켜져 있고 BucketOwnerEnforced, AES256 기본 암호화, prevent_destroy가 걸려 있습니다. 라이프사이클 규칙은 미완료 멀티파트 업로드를 하루 뒤 정리하는 것 하나뿐이고 만료 규칙은 없습니다. 예전에 다른 버킷에서 범위를 지정하지 않은 만료 규칙 때문에 결과물이 사라진 적이 있어서, 지켜야 하는 데이터가 들어가는 버킷에는 만료를 아예 두지 않았습니다.
DNS도 의도가 있습니다.
# DNS-only is intentional: Cloudflare proxying here would add a second CDN,
# reduce CloudFront/CFRC cache efficiency, and obscure CloudFront Range metrics.
resource "cloudflare_dns_record" "media_cache" {
name = var.cloudfront_domain_name
type = "CNAME"
ttl = 300
proxied = false
content = aws_cloudfront_distribution.cache.domain_name
}게이트웨이 hostname은 전부 Cloudflare 프록시를 타는데 CDN 배포 도메인만 DNS-only입니다. CDN을 두 겹으로 쌓으면 캐시 히트율이 떨어지고 범위 요청 지표가 흐려지기 때문입니다. 같은 조직의 같은 DNS 존 안에서도 레코드마다 판단이 다를 수 있습니다.
마지막으로 S3 클라이언트 설정에서 한 번 걸려 넘어진 부분이 있습니다.
# Pin the regional endpoint explicitly. Without this, botocore can create
# a presigned URL on the global virtual-host endpoint; S3 then redirects it
# to the bucket region and the changed Host invalidates the SigV4 signature.
return boto3.client(
"s3",
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
endpoint_url=f"https://s3.{region}.amazonaws.com",
config=Config(
signature_version="s3v4",
s3={"addressing_style": "virtual"},
),
region_name=region,
)SigV4 서명에는 Host 헤더가 포함됩니다. 글로벌 엔드포인트로 서명한 URL이 리전 엔드포인트로 리다이렉트되면 Host가 달라져 서명이 무효가 됩니다. 리다이렉트를 따라간 결과가 403이라 처음에는 권한 문제처럼 보였습니다. 이 문제에는 클라이언트가 실제로 어떤 엔드포인트와 서명 방식으로 만들어지는지 확인하는 테스트를 붙여뒀습니다.
12. 관측: 헬스체크가 초록인 채로 죽는 것을 잡습니다
3장의 사고가 이 장의 전부입니다. /healthz는 프로세스가 살아 있다는 것만 증명하고 추출이 전멸했다는 사실은 증명하지 못합니다.
그 빈자리를 메우려고 추출 결과를 라벨로 나눈 카운터를 만들었습니다.
PLAY_EXTRACT = Counter(
"gateway_play_extract_total",
"Outcomes of /play yt-dlp extraction attempts.",
labelnames=("outcome",),
)outcome 값은 코드에 정의된 그대로입니다.
| outcome | 의미 |
|---|---|
success | muxed 포맷을 얻었고 S3 write-through를 예약했습니다 |
timeout | 추출이 20초 상한을 넘겼습니다 |
bot_block | yt-dlp가 “Sign in to confirm you’re not a bot”을 반환했습니다 |
unavailable | 비공개, 삭제, 이용 불가 영상입니다 |
no_muxed | 포맷은 왔지만 muxed mp4/webm이 없습니다 |
invalid_input | video_id 검증에 실패했습니다 (정상 운영에서는 0에 가깝습니다) |
other | 분류되지 않은 예외입니다 |
분류는 문자열 매칭으로 합니다.
def _classify_extract_error(exc: BaseException) -> str:
msg = str(exc).lower()
# YouTube bot-challenge wording is fairly stable across yt-dlp releases.
if "sign in to confirm" in msg or "are you a bot" in msg:
return "bot_block"
if (
"video unavailable" in msg
or "private video" in msg
or "removed by the uploader" in msg
or "this video is no longer available" in msg
):
return "unavailable"
...문자열 매칭은 취약합니다. 상류 문구가 바뀌면 분류가 조용히 other로 흘러갑니다. 그래도 이 라벨을 만든 이유는 명확합니다. bot_block과 unavailable은 대응이 완전히 다릅니다. 앞의 것은 인프라 사고이고 뒤의 것은 그냥 그 영상이 사라진 것입니다. 실패율만 보면 둘이 섞여서 아무 판단도 할 수 없습니다.
인증 실패도 사유별로 셉니다.
AUTH_FAIL = Counter(
"gateway_auth_fail_total",
"Authentication failures by reason and path.",
labelnames=("path", "reason"),
)로그에는 서명 자체가 아니라 존재 여부와 길이, 요청 IP, User-Agent 앞부분, 요청 ID를 남깁니다. 만료 시각과 현재 시각을 함께 찍어두면 시계 문제인지 위조인지 바로 구분됩니다.
블랙박스 프로브는 QA 1개, 프로덕션 4개입니다.
targets:
staticConfig:
static:
# Public on-prem fleet through the Cloudflare Tunnel. The base endpoint
# targets the fleet Service; A/B/C target individual on-prem Services.
- https://<fleet-endpoint>/healthz
- https://<worker-a-endpoint>/healthz
- https://<worker-b-endpoint>/healthz
- https://<worker-c-endpoint>/healthz
labels:
service: media-gateway
environment: prodfleet과 개별 노드는 함께 찔러야 합니다. 개별 노드 하나가 죽는 것과 전부 죽는 것은 심각도가 다르고, 프로브를 한 종류만 두면 그 차이를 구분할 수 없습니다.
알럿은 다섯 개입니다.
| 알럿 | 심각도 | 조건 |
|---|---|---|
MediaGatewayProbeFailed | warning | 단일 엔드포인트 프로브가 5분간 실패합니다 |
MediaGatewayProdAllDown | critical | fleet과 A/B/C가 전부 1분간 실패합니다 |
MediaGatewayExtractFailHigh | critical | 5분간 추출 실패율이 30%를 넘습니다 |
MediaGatewayCertExpiring | warning | 인증서 만료가 7일 미만입니다 |
MediaGatewayProbeSlow | warning | 프로브가 10분간 5초를 넘습니다 |
단일 실패와 전면 실패는 따로 다룹니다. 노드 하나가 죽는 것은 fleet Service가 흡수하므로 경고면 충분하고 전부 죽으면 재생이 멈추므로 즉시 호출입니다.
여기서 짚어둘 것이 하나 있습니다. MediaGatewayExtractFailHigh는 아직 실제로는 울릴 수 없습니다.
게이트웨이의 /metrics를 Prometheus가 스크레이프해야 하는데, 게이트웨이가 온프렘에 있어 별도 경로가 필요하고 그 설정이 아직 붙지 않았습니다. 입력 시계열이 없는 알럿은 조용히 아무 일도 하지 않습니다. 알럿이 존재한다는 사실과 알럿이 작동한다는 사실은 다릅니다. 만들어놓고 한 번도 울려보지 않으면 없는 것과 같습니다.
같은 종류의 미완성이 하나 더 있습니다. /readyz는 이름과 달리 스토리지와 추출기의 준비 상태를 전혀 확인하지 않고 조건 없이 200을 돌려줍니다.
@app.get("/readyz")
def readyz() -> dict[str, str]:
return {"status": "ok"}이름이 앞서 나간 상태입니다. 지금은 liveness와 사실상 같은 값이고 스토리지 접근성을 실제로 확인하도록 채우는 것이 남은 숙제입니다.
접근 로그에는 소음 필터를 하나 걸어뒀습니다.
class _ProbeAccessLogFilter(logging.Filter):
"""Drop routine probe access records while preserving application logs."""
_paths = ("/healthz", "/readyz", "/metrics")30초마다 다섯 군데에서 프로브가 들어오면 로그가 헬스체크로 가득 찹니다. 애플리케이션 로그만 남기고 프로브 접근 로그는 버립니다.
13. 배포: 값이 흐르는 경로
배포 구조는 이 서비스의 특이한 요구를 그대로 반영합니다.
CI는 main 푸시에서 테스트를 돌리고 amd64 이미지를 하나 만들어 여러 레지스트리에 같은 커밋 SHA 태그로 올립니다. 환경별로 레지스트리가 나뉘어 있고 온프렘 노드와 클라우드 워크로드가 서로 다른 레지스트리를 쓰던 시기를 지나면서 양쪽을 다 채우는 형태가 됐습니다.
테스트 단계는 세 가지입니다. ruff는 런타임을 깨는 오류 규칙만 켜고, mypy는 yt-dlp 경계의 타입 없는 부분을 허용한 채로 돌리고, pytest는 전체를 돕니다. 기존 스타일 지적을 전부 켜면 배포 변경이 스타일 정리에 발목을 잡히기 때문에 검사 범위를 좁혔습니다.
배포 잡은 GitOps 저장소에 있는 worker A/B/C의 값 파일 세 개를 한꺼번에 갱신합니다.
for values in "${WORKER_VALUES_FILES[@]}"; do
sed -i "s|repository: .*|repository: ${IMAGE_REPOSITORY}|" "$values"
sed -i "s|tag: .*|tag: ${IMAGE_TAG}|" "$values"
sed -i "s|name: .*-pull|name: ${IMAGE_PULL_SECRET}|" "$values"
done세 파일을 함께 바꾸는 것이 중요합니다. 노드마다 다른 이미지가 돌면 같은 video_id가 어느 노드로 가느냐에 따라 동작이 달라지는데, 이런 상태는 재현이 거의 불가능한 버그로 이어집니다.
ArgoCD 쪽은 앱 네 개로 구성됩니다. QA용 앱 하나와 프로덕션 worker A/B/C용 앱 셋이고 각 앱은 자기 대상의 값 파일 하나만 바라봅니다. 현재 클러스터에서는 이 셋이 ApplicationSet 하나로 묶여 있고 여기에 fleet Service만 관리하는 앱이 하나 더 붙습니다. 워크로드를 만드는 앱과 fleet 진입점을 만드는 앱을 나눈 이유는 수명이 다르기 때문입니다. worker는 늘거나 줄지만 fleet 진입점은 그대로 있어야 합니다.
앱을 노드 수만큼 나눈 것 자체가 이 시스템의 특성입니다. 보통은 하나의 Deployment에 replica 3을 주지, 앱을 셋으로 쪼개지 않습니다. 그런데 여기서는 각 파드가 서로 다른 공개 주소를 가져야 백엔드가 해시로 특정 노드를 지목할 수 있습니다. replica 3은 Service 하나 뒤에 숨어서 어느 파드로 갈지 알 수 없으므로 이 목적에 맞지 않습니다.
시크릿은 ExternalSecret으로 시크릿 저장소에서 옵니다. 프로덕션과 QA가 환경별로 분리된 경로를 쓰고, 갱신 주기는 1분입니다. 여기에 재미있는 운영 흔적이 하나 있습니다.
# CORS_ALLOWED_ORIGINS_CSV arrives through envFrom, which the container only reads at
# start, so a secret change needs a pod replacement to take effect. Bump this instead of
# restarting out of band.
podAnnotations:
cors-allowlist-revision: "4"시크릿 저장소의 값을 바꿔도 이미 뜬 컨테이너의 환경변수는 바뀌지 않습니다. 파드를 교체해야 반영되는데 온프렘 변경은 GitOps로만 하기로 했으니 손으로 재시작할 수 없습니다. 그래서 값 파일의 애노테이션 숫자를 올려 파드 교체를 유발합니다. 규칙을 우회하지 않으면서 같은 효과를 내는 방법입니다.
14. 마무리
이 게이트웨이는 임베드 플레이어에서는 키 조절을 할 수 없다는 제약 하나에서 출발했습니다. 영상 바이트가 우리가 CORS 헤더를 통제하는 호스트에서 나와야 했고 그 요구를 따라가다 추출과 원본 다운로드를 같은 노드에 묶게 됐습니다. 그 노드가 클라우드를 떠난 이유도 성능이나 비용이 아니라 출구 IP의 평판이었습니다.
지금 구조를 지탱하는 축은 넷입니다. 캐시 히트는 CloudFront로 보내고 미스만 게이트웨이가 처리해 무거운 경로와 가벼운 경로를 분리했습니다. 영상 ID를 해시해 같은 영상이 항상 같은 노드로 가게 만들어 singleflight가 실제로 작동합니다. 게이트웨이에 일을 시키는 권한은 HMAC으로, CDN에서 파일을 받는 권한은 RSA로 나눠 서명합니다. 캐시의 단위는 곡이 아니라 영상입니다.
아직 끝나지 않은 것도 있습니다. /readyz는 이름과 달리 아무것도 확인하지 않고 추출 실패율 알럿은 스크레이프 경로가 붙기 전까지 울리지 않습니다. r2Key라는 옛 이름과 쓰이지 않는 _songId도 그대로 남아 있습니다.
이 게이트웨이가 필요했던 이유인 키 조절 자체는 확장 프로그램 없이 브라우저에서 영상 키 조절하기에서 다룹니다.
시리즈 · 노래책과 신청곡
4 / 5개떡같이 말해도 알아듣고, 키를 바꾸고, 클립으로 남기기까지 노래책을 이루는 조각들.
- 1.!신청 사랑하긴 그 긴거 : 개떡같이 말해도 찰떡같이 알아듣는 LLM 기반 신청곡 매칭 로직
- 2.노래 제목과 Musixmatch 트랙 매칭하기 - LLM Agent와 HITL(Human-in-the-Loop)
- 3.확장 프로그램 없이 브라우저에서 영상 키 조절하기
- 4.유튜브 임베드를 벗어나 직접 재생하기 : 광고 없이 video 태그로 유튜브 영상 서빙하기
- 5.노래클립 자동생성 파이프라인 - 방송 녹화, STT, Agent 판정
시리즈 · Vibe Infra
5 / 5AI와 함께 인프라를 굴리며 만든 장애, 그리고 거기서 세운 운영 원칙들.
- 1.Vibe Infra로 장애를 만들고, 장애에서 배우다
- 2.비용을 줄이려 넣은 S3 Lifecycle 한 줄이 만든 장애
- 3.단일 k3s 노드에서 110개 Pod 제한을 넘기기
- 4.라이브 스트리밍 채팅 수집 시스템을 KEDA와 Karpenter로 확장한 방법
- 5.유튜브 임베드를 벗어나 직접 재생하기 : 광고 없이 video 태그로 유튜브 영상 서빙하기