2GB 파일을 다운로드하지 않고 MP4 메타데이터 읽기
MP4는 재생 시간과 코덱 정보를 `moov` 박스에 보관합니다. 다음은 HTTP Range를 통해 기가바이트 단위의 미디어 페이로드를 건너뛰고 해당 박스만 가져오는 방법입니다.
저희에게는 수천 개의 업로드된 동영상의 길이, 해상도, 코덱 정보만 필요한 워커가 있었습니다. 단순한 버전은 각 파일을 전체 다운로드하고, 헤더의 몇백 바이트를 읽은 다음, 나머지는 버렸습니다. 2GB 동영상이라면 네 개의 숫자를 알아내기 위해 2GB를 전송하는 셈이었습니다. 이 게시물에서는 MP4의 레이아웃을 이해하고 Range 요청을 사용하여 중요한 부분만 가져옴으로써, 전체 파일 대신 몇 킬로바이트만 전송하여 HTTP를 통해 해당 헤더를 직접 읽는 방법을 보여줍니다.
문제: 2기가바이트 중 200바이트가 필요한 경우
duration(재생 시간), width(너비), height(높이), codec(코덱)과 같은 메타데이터는 작은 구조화된 헤더에 위치합니다. 실제 오디오 및 비디오 샘플이 나머지 부분이며, 긴 동영상의 경우 이 나머지 부분이 거의 전부입니다. 2GB 파일은 몇 킬로바이트의 메타데이터와 1.999GB의 미디어 샘플을 담고 있을 수 있습니다.
서비스에서 가끔 동영상 하나를 처리하는 경우, 헤더를 읽기 위해 전체를 다운로드하는 것은 낭비이지만 감당할 수는 있습니다. 작업자 플릿(worker fleet)이 수천 개의 업로드 파일을 분류하는 경우, 전체 다운로드가 모든 것을 압도합니다. 객체 스토리지의 이그레스(egress)를 소모하고, 대용량 전송 시간 동안 연결을 계속 열어두며, 1초 미만으로 끝나야 할 작업에 실질적인 지연 시간(latency)을 발생시킵니다. 전송 시간은 작업량에 비례하지 않고 파일 크기에 비례하며, 이는 비용을 지불하기에 잘못된 대상입니다.
목표는 비용이 미디어가 아닌 메타데이터에 비례하도록 만드는 것입니다. 이를 위해서는 두 가지가 필요합니다. 파일 내에 메타데이터가 어디에 있는지에 대한 맵, 그리고 주변 바이트를 가져오지 않고 해당 영역만 가져올 방법입니다.
MP4가 박스(box) 단위로 구성되는 방식
MP4 파일은 박스(box)의 평면적인 시퀀스이며, 때로는 아톰(atom)이라고도 불립니다. 박스는 가장 단순한 형태의 컨테이너입니다. 박스는 8바이트 헤더로 시작합니다. 이 헤더는 4바이트 빅엔디언 크기와 ftyp, moov, mdat 같은 4바이트 타입으로 구성됩니다. 크기는 헤더 자체를 포함하므로, 크기가 8인 박스는 헤더만 있는 빈 박스입니다. 헤더 다음에는 박스 콘텐츠가 오며, 이 콘텐츠는 지정된 크기를 다 채울 때까지 이어지고, 그 후에 다음 박스가 시작됩니다.
일반적인 파일의 최상위 레벨에서는 다음과 같은 소수의 박스를 볼 수 있습니다.
ftyp: 파일 타입 및 호환되는 브랜드. 크기가 작으며, 항상 앞부분 근처에 위치합니다.moov: 무비 박스. 메타데이터 컨테이너입니다. 재생 시간, 트랙 목록, 해상도, 코덱 매개변수, 샘플 테이블이 모두 이 안에 들어 있습니다. 미디어에 비해 크기가 작으며, 보통 킬로바이트에서 수 메가바이트 정도입니다.mdat: 미디어 데이터. 오디오와 비디오의 원시 샘플 바이트입니다. 실제 비디오 파일의 경우, 이 박스가 파일의 압도적인 대부분을 차지합니다.
다른 박스들이 있을 수 있으며, 순서는 형식에 의해 고정되어 있지 않습니다. 저비용 파싱을 가능하게 하는 두 가지 사실은 다음과 같습니다. 첫째, 모든 박스는 헤더에 자신의 크기를 명시하므로, 현재 박스의 콘텐츠를 읽지 않고도 다음 박스가 시작되는 위치를 찾을 수 있습니다. 둘째, 원하는 메타데이터는 전부 moov 안에 있으며, mdat에는 기본 메타데이터에 필요한 것이 아무것도 들어있지 않습니다. 따라서 전략은 명확해집니다. 박스 헤더를 읽고, mdat는 연산을 통해 건너뛰고, moov 안의 바이트만 읽는 것입니다.
Range 요청으로 박스 헤더 하나 읽기
모든 단계는 알려진 오프셋에서 작은 바이트 범위를 읽을 수 있는지에 달려 있습니다. HTTP 상에서 그것은 Range 요청입니다. N부터 M까지의 바이트를 요청하면, 범위를 지원하는 서버는 206 Partial Content와 함께 해당 바이트만 응답합니다. 서버는 Accept-Ranges: bytes 헤더로 이를 알리고, S3 호환 스토리지 같은 객체 저장소는 기본적으로 이를 지원합니다.
다음은 정확한 바이트 범위를 가져오는 헬퍼와, 주어진 오프셋에서 단일 박스 헤더를 읽고 디코딩하는 헬퍼입니다.
package mp4
import (
"encoding/binary"
"fmt"
"io"
"net/http"
)
// fetchRange returns bytes [start, start+length) from url using an HTTP Range request.
func fetchRange(client *http.Client, url string, start, length int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
end := start + length - 1
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
return nil, fmt.Errorf("range not honored, got status %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, length))
}
type boxHeader struct {
size int64 // total box size including the header
boxType string
headerSize int64 // 8 for 32-bit size, 16 for 64-bit size
}
// readBoxHeader reads the 8 byte header at offset, expanding to 16 bytes for a 64-bit size.
func readBoxHeader(client *http.Client, url string, offset int64) (boxHeader, error) {
buf, err := fetchRange(client, url, offset, 8)
if err != nil {
return boxHeader{}, err
}
size := int64(binary.BigEndian.Uint32(buf[0:4]))
boxType := string(buf[4:8])
return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
}
하나의 헤더를 가져오는 데는 전송 시 8바이트와 HTTP 오버헤드가 필요합니다. 요청 및 응답 헤더의 오버헤드는 8바이트 본문에 비해 훨씬 크므로, 실제로는 박스 헤더당 수백 바이트의 비용이 발생합니다. 몇 개의 최상위 박스를 가진 파일은 이러한 요청이 몇 번 발생하는 비용이 듭니다. 이것이 바로 핵심입니다. 이제 전송은 파일의 크기가 아니라 박스의 수에 비례하게 됩니다.
산술 연산으로 박스 탐색 및 mdat 건너뛰기
헤더 리더가 있으면 최상위 탐색은 루프입니다. 현재 오프셋에서 헤더를 읽고 타입을 확인한 다음 무엇을 할지 결정합니다. 만약 타입이 moov이면 메타데이터를 찾은 것이므로 전체 박스를 가져와 파싱할 수 있습니다. 거대한 mdat을 포함한 다른 어떤 타입이라도 그 내용을 전혀 읽지 않습니다. 오프셋에 그 크기를 더하고 다음 박스로 이동합니다.
// findMoov walks top-level boxes and returns the byte range of the moov box.
// It never reads the contents of mdat or any other box it skips.
func findMoov(client *http.Client, url string, fileSize int64) (start, length int64, err error) {
var offset int64
for offset < fileSize {
h, err := readBoxHeader(client, url, offset)
if err != nil {
return 0, 0, err
}
if h.size < h.headerSize {
return 0, 0, fmt.Errorf("invalid box size %d at offset %d", h.size, offset)
}
if h.boxType == "moov" {
return offset, h.size, nil
}
// Skip this box entirely. This is the key move: for mdat we jump
// past gigabytes of media without transferring any of it.
offset += h.size
}
return 0, 0, fmt.Errorf("moov box not found")
}
offset += h.size 줄에서 비용 절감 효과가 나타납니다. 탐색이 1.9GB mdat에 도달하면 스트리밍하거나, 버퍼링하거나, 건드리지 않습니다. 8바이트 헤더를 읽고, 크기를 파악한 다음, 그 크기를 오프셋에 더합니다. 다음 요청은 mdat 뒤에 오는 박스에 도달합니다. 미디어 데이터는 순전히 오프셋에 대한 산술 연산으로 건너뜁니다.
findMoov가 범위를 반환하면, 한 번의 Range 요청이 더 moov 박스 전체를 메모리로 가져오고, 여기서 중첩된 박스를 파싱하여 재생 시간과 트랙 정보를 추출합니다. moov는 작기 때문에, 전체를 읽는 것이 그 안에서 하위 범위를 지정하는 것보다 저렴하고 간단합니다.
전송된 바이트의 차이는 극명합니다.
| 접근 방식 | 2GB 파일에 대해 전송된 바이트 | 요청 수 |
|---|---|---|
| 전체 다운로드 | ~2,000,000,000 | 1 |
| Range 탐색, moov가 앞쪽에 위치 | 수 KB에서 낮은 MB | 3~6 |
| Range 탐색, moov가 끝에 위치 | 동일하게 수 KB에서 낮은 MB | 몇 개 추가 |
Range 탐색의 전송량은 미디어 크기와 관계없이 moov의 크기에 몇 개의 박스 헤더를 더한 것입니다. 200MB 동영상과 20GB 동영상을 검사하는 데 드는 비용은 거의 같습니다.
핵심: moov 배치가 지연 시간을 결정합니다
형식은 moov의 위치를 고정하지 않으며, 그 배치는 워크(walk)가 얼마나 빨리 끝나는지에 대한 모든 것을 바꿉니다.
일부 파일은 faststart로 작성되는데, 이는 moov가 ftyp 바로 뒤, mdat 앞인 파일의 맨 앞으로 이동됨을 의미합니다. 이러한 파일은 플레이어가 끝까지 탐색하지 않고도 메타데이터를 즉시 읽을 수 있기 때문에 스트리밍용으로 만들어집니다. faststart 파일의 경우 워크는 첫 번째 또는 두 번째 헤더 읽기에서 moov를 찾으면 완료됩니다.
다른 파일들은 moov를 mdat 뒤, 즉 파일의 끝에 둡니다. 이는 작성기가 모든 샘플을 다 쓸 때까지 무비 박스의 최종 크기를 알 수 없어서 moov를 마지막에 추가하기 때문에, 녹화 파일이나 단일 순차 패스로 작성되는 모든 파일에서 일반적입니다. 이러한 파일의 경우 최상위 워크는 moov에 도달하기 위해 mdat를 건너뛰어야 합니다. 다행인 점은 mdat를 건너뛰는 것은 여전히 offset += h.size일 뿐이므로, 미디어를 다운로드하는 것이 아니라 한 번의 추가 헤더 읽기 비용만 발생한다는 것입니다.
알아둘 가치가 있는 최적화가 있습니다. 첫 번째 정방향 단계가 mdat에 도달하면 moov가 파일 끝에 있다고 추측하고, 마지막 청크에 대한 Range 요청으로 파일의 마지막 부분을 직접 가져온 다음, 역방향으로 스캔하거나 해당 끝 영역을 파싱할 수 있습니다. 일반적인 휴리스틱은 마지막 256KB 정도를 요청하여 거기에서 moov 유형을 찾는 것입니다. 이렇게 하면 2단계 워크가 단일 끝 가져오기로 바뀝니다. 이는 필수 사항이 아닌 최적화이며, 일반적인 정방향 워크는 어느 쪽이든 이미 미디어 다운로드를 피합니다.
실제로 비용이 더 많이 드는 한 가지 경우는 선언된 크기가 0이거나 파일 끝까지 확장되는 mdat인데, 일부 형식에서는 이를 끝까지 실행하라는 의미로 사용합니다. 이런 경우를 보면 파일의 나머지 부분을 미디어로 처리하고 끝(tail) 전략으로 전환하여 moov를 찾으십시오.
64비트 크기 및 중첩된 박스 처리
장난감 수준의 파서와 실제 파일을 처리할 수 있는 파서를 구분하는 두 가지 세부 사항이 있습니다.
첫 번째는 큰 크기입니다. 32비트 크기 필드는 최대 4GB 미만으로, 큰 mdat에는 충분하지 않습니다. 이 형식은 이스케이프 값을 사용하여 이 문제를 처리합니다. 32비트 크기가 1이면 실제 크기는 유형 바로 뒤 8바이트에 저장된 64비트 값이며, 헤더는 8바이트 대신 16바이트가 됩니다. 크기가 0이면 박스가 파일 끝까지 이어진다는 의미입니다. 헤더 리더는 32비트 숫자를 신뢰하기 전에 이러한 경우를 확인해야 합니다.
func readBoxHeaderFull(client *http.Client, url string, offset, fileSize int64) (boxHeader, error) {
buf, err := fetchRange(client, url, offset, 16) // read enough for a 64-bit header
if err != nil {
return boxHeader{}, err
}
size := int64(binary.BigEndian.Uint32(buf[0:4]))
boxType := string(buf[4:8])
switch size {
case 1:
// 64-bit largesize follows the type field.
size = int64(binary.BigEndian.Uint64(buf[8:16]))
return boxHeader{size: size, boxType: boxType, headerSize: 16}, nil
case 0:
// Box extends to the end of the file.
size = fileSize - offset
return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
default:
return boxHeader{size: size, boxType: boxType, headerSize: 8}, nil
}
}
두 번째 세부 사항은 중첩입니다. moov는 리프(leaf)가 아닙니다. 그 안에는 전체 길이와 타임스케일을 담은 동영상 헤더인 mvhd와, 트랙당 하나의 trak이 있습니다. 각 trak 안에는 mdia, minf, stbl 체인이 있고, 마지막으로 코덱 이름을 지정하는 샘플 설명인 stsd가 있습니다. 중첩을 파싱하는 것은 동일한 헤더 로직을 재귀적으로 사용하지만, HTTP를 통하지 않고 메모리 내의 moov 바이트에 대해 수행합니다. 왜냐하면 이미 전체 박스(box)를 가져왔기 때문입니다. 최상위 레벨을 탐색했던 것과 같은 방식으로 자식들을 탐색합니다. 즉, 헤더를 읽고, 유형에 따라 동작하고, 크기만큼 앞으로 이동합니다. 유일한 차이점은 trak과 같은 컨테이너 박스는 내용을 건너뛰는 대신 그 안으로 들어간다는 것을 의미한다는 것입니다.
moov 내부에서 Range-request를 할 필요가 없습니다. moov를 한 번의 페치(fetch)로 가져오는 것의 핵심은 원하는 모든 것이 이제 로컬에 있으며, 로컬 파싱은 간단한 바이트 슬라이스 연산이라는 점입니다.
파일 전체를 다운로드하는 것이 더 나은 경우
Range 파싱은 파일이 원격에 있고, 크기가 크며, 작은 헤더만 필요할 때 적합한 도구입니다. 이 조건 중 하나라도 반대가 되면 셈법이 달라집니다.
- 파일이 로컬에 있거나 이미 메모리에 있는 경우. 절약할 전송이 없습니다. 파일을 열어 읽거나 라이브러리에 전달하세요. Range 로직은 복잡성만 더할 뿐입니다.
- 서버가 Range를 지원하지 않는 경우.
Accept-Ranges: bytes를 확인하거나, 범위를 보내206응답을 확인하세요. 만약 전체 본문과 함께200응답을 받는다면, 서버가 범위를 무시한 것이고 결국 모든 것을 다운로드하게 됩니다. 일부 CDN과 잘못 구성된 원본 서버가 이렇게 동작합니다. - 어차피 파일의 대부분을 읽어야 하는 경우. 다음 단계가 트랜스코딩, 모든 장면 썸네일링, 또는 전체 파일 해싱이라면, 곧 모든 바이트를 가져오게 될 것입니다. 헤더를 별도로 읽는 것은 실제 작업 전에 왕복 시간을 추가할 뿐입니다.
- 필요한 메타데이터가
moov에 없는 경우. 적응형 스트리밍에 사용되는 종류인 단편화된 MP4는 파일 전체에 걸쳐moof박스에 타이밍 정보를 분산시킵니다. 최상위 요약 정보가 아닌 조각별 세부 정보가 필요하다면, 단일 헤더 가져오기로는 얻을 수 없으며 접근 패턴도 다릅니다.
원격 객체 스토리지, 대용량 미디어 파일, 그리고 재생 시간과 코덱 정보만 필요한 일반적인 워커의 경우, Range 탐색은 비용이 파일 크기에 비례하던 작업을 메타데이터 크기에 비례하는 작업으로 바꿔줍니다. 이것이 바로 수천 개의 비디오를 저렴하게 검사할 수 있는 플릿과, 대부분의 시간과 이그레스 예산을 즉시 버려지는 바이트를 옮기는 데 소비하는 플릿의 차이입니다.
관련 글
Redis 키스페이스 알림 및 임대를 사용한 GPU 워커 풀의 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로, 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스(lease)와 키스페이스 알림(keyspace notification)으로 구축된 바쁨을 인지하는 스케줄러가 있습니다.
MongoDB 변경 스트림을 이용한 Cron 스케줄 핫스왑
cron을 하드코딩하면 스케줄이 변경될 때마다 재배포해야 합니다. 스케줄을 데이터베이스에 저장하고 데몬이 변경 스트림으로 수정 사항에 반응하도록 하십시오. 여기에 그 설계와 장애 처리 방법이 있습니다.
CAS와 하트비트를 사용한 단일 실행기 작업을 위한 리스 잠금
일반적인 락은 홀더가 죽었을 때 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기서는 `compare-and-swap` 획득과 하트비트를 사용하여 리스를 구축하는 방법과, 설계를 결정하는 실패 모드를 다룹니다.
멱등성 업서트를 위한 결정적 ULID
데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등적이어야 합니다. 콘텐츠 기반 ID는 이를 추가 비용 없이 가능하게 합니다. 여기에 그 유도 과정과 안전성을 유지하는 한 가지 불변식이 있습니다.
처리 속도보다 빠르게 채워지는 워커 큐를 위한 백프레셔
생산자가 작업자보다 더 빨라지면, 무한 큐는 부하를 흡수하는 것이 아니라 충돌을 지연시킬 뿐입니다. 역압력이 시스템을 계속 유지하는 방법은 다음과 같습니다.
Firestore의 MongoDB 호환성에서 `retryWrites=false`의 함정
Firestore는 MongoDB 와이어 프로토콜을 지원하지만 재시도 가능 쓰기는 거부합니다. 드라이버의 기본값이 활성화(on)인 경우, 쓰기 작업이 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.