HLS: 야매로 알아보는 HTTP Live Streaming A to Y

d3fau1t·2021년 12월 5일
0

뻘짓

목록 보기
3/9

어느날 웹에서 스트리밍 영상을 보면서 문득 생각이 들었다.

다운로드 받고싶은데 저장버튼이 없다.
소스 까보면 영상파일 링크 달려있지 않을까?

그래서 스트리밍중인 영상이 있는 페이지를 개발자 도구를 열어서 확인해봤는데 아래와 같이흔히 알고있는 .mp4 파일의 링크가 아니라

m3u8 파일의 링크가 있는걸 볼 수 있었다.

그 뒤에는 ts파일이 계속 전송되고 있었고 이게 뭔가 싶었다.

그래서 일단은 먼저 전송된 m3u8파일을 열어보니 *.ts 파일 리스트인듯 보였다.

같이 전송된 ts파일들을 다운로드 받으니 영상의 일정 구간만 재생되는걸 확인할 수 있었다.
그래서 이 두 종류의 파일이 영상 전송에 관련있는 부분이라고 판단하여 조금 더 파보기로 했다.

이 문서에서는 여러 ts파일이 전송되는 사례를 다룸.
mp4링크가 제공되는 경우도 마찬가지로 여러 조각으로 분할되어 스트리밍 되는 형식이지만 ts 파일로 쪼개서 여러개 전송안하고 한번에 쭉 전송하는 형태.

이런 경우엔 딱히 분할되지도 않았으니 전체 영상정보가 궁금하면 직접 url을 통해 내려받으면 문제는 해결됨.

그래서 m3u8? ts? 그게 뭔데?

HLS 전송방식에 사용되는 m3u8 그리고 ts

HLS : Http Live Streaming

HLS(HTTP Live Streaming)는 Apple에서 iOS 3.0 출시와 함께 QuickTime 패키지의 일부로 2009년에 내놓은 프로토콜.

이 프로토콜에서는 스트리밍 데이터를 MPEG-2 Transport Stream에 담아 시간 단위로 잘게 쪼개서 전송함.
그리고 어떤 파일을 재생해야 하는 지에 대한 정보는 m3u8 파일을 이용하여 플레이어에 전달함.

기존 스트리밍 방식 대비 HLS를 사용할때 얻는 이점이 몇 가지 있는데 그 내용은 아래와 같다.

데이터 흐름을 현재 네트워크의 대역폭에 맞게 조정할 수 있음.
프로토콜 내부의 모든 쿼리가 HTTP를 통해 실행되기 때문에 기존에 구축된 인프라에 배포하고 방화벽이나 프록시 서버를 통해 원활한 실행이 가능하다.

m3u8과 ts

  • 원래의 파일 확장자는 .m3u이며 UTF-8을 명시할 경우 .m3u8이 되는것.
    • m3u는 Latin-1 문자셋으로만 작성되기 때문에 기본적인 작성은 하겠다만 내용이 다소 부실할 수 있음.
    • m3u8는 UTF-8 문자셋으로 작성되기 때문에 재생되는 파일에 대한 여러 추가 정보 기록 가능.
  • 하나 혹은 여러 개의 멀티미디어 정보를 텍스트형식으로 기록해둔 파일이다.
    • 포함되는 파일 경로는 보통 ts라는 확장자를 가진 미디어파일을 가리킨다.
  • 통상적으로 HLS 방식으로 m3u8과 여러 ts파일이 전송된다.

ts : transport stream

m3u8 메타 데이터 명령어

실제 사용 사례를 알기전 m3u8 파일에서 사용되는 명령어들이 뭐가 있는지 알고 넘어가는 편이 이후의 내용 이해에 도움이 될 것 같아서 작성하였다.

명령사용법설명
#EXTM3U#EXTM3U파일의 첫 줄에 명시.
(‘m3u8 포맷이다’ 라고 알려주기 위함.)
#EXTINF#EXTINF: [재생시간 (단위: 초)]<제목> 컨텐츠 재생 시간과 제목.
#EXT-X-TARGETDURATION#EXT-X-TARGETDURATION: [시간 (단위: 초)]파일 목록에 나열된 각 파일의 최대 재생시간 알려주기 위함.
#EXT-X-ENDLIST#EXT-X-ENDLIST플레이 리스트에서 재생할 컨텐츠가 더 이상 없음.
(뒤에 기록된 정보는 모두 무시함)
#EXT-X-DISCONTUNUITY#EXT-X-DISCONTINUITY이 명령어가 사용된 라인을 기준으로 컨텐츠 정보가 변경되었음을 알려주기 위함.
(컨텐츠의 파일 포맷, 수, 인코딩 정보, 재생시간등의 변경사유로 플레이어가 새 정보를 확인해야한다고 알려주기 위함.)
#EXT-X-MEDIA-SEQUENCE#EXT-X-MEDIA-SEQUENCE: [번호]플레이어가 어떤 파일부터 재생해야할지 알려주기 위함.
#EXT-X-KEY#EXT-X-KEY:[암호화방법], [키값]미디어복제방지 기술과 관련있음.
m3u8파일의 링크를 얻어서 vlc로 재생한다거나 할때 플레이어에 내장된 암호를 복호화 할 수 없다면 영상 재생을 할 수 없게끔 하기 위함.
#EXT-X-STREAM-INF#EXT-X-STREAM-컨텐츠에 대한 정보 제공을 위함.
[BANDWIDTH, PROGRAM-ID, CODEC, RESOLUTION]

실사용 사례

개발자도구를 열어놓고 Twitch나 기타 스트리밍서비스에 접속한 뒤 임의의 채널의 영상을 재생하면 m3u8, ts 파일이 전송되는걸 확인할 수 있다.

요즘은 스트리밍 리소스를 노출시키지 않기 위해 플레이어 레벨에서 캡슐화 하고 있는 것 같다.

지금까지의 내용으로 어떻게 돌아가는건지 생각해보니 아래와 같은 결과가 나온다.

  • .m3u8 파일을 받은 뒤 안에 기록된 ts파일을 서비스
  • 재생된 영상은 제거하고 1번 과정을 다시 거쳐 재생될 영상 준비

한개의 m3u8를 받아서 안에있는 ts 경로에 위치한 영상들을 뿌려주는 작업 반복함.

.m3u8파일을 열어보면 모든 ts파일이 전송되고나서 EXT-X-MEDIA-SEQUENCE의 값이 변경되는 것을 볼 수 있음.
요약하면 해당 시퀀스의 ts를 다 내려받고 다음 시퀀스의 ts를 내려받는것을 반복.

스트리밍중인 영상은 어떻게 다운로드 할까?

브라우저단에 request되는 ts파일들이 재생되고있는 영상의 리소스이다.
실시간 스트리밍중인 영상을 본다면 무수한 m3u8과 ts파일을 받게되는데 이 상황에서 ts파일의 경로를 모두 획득하여 다운로드 받는건 가능하겠지만 매우 번거로운 작업이 될 것이다.

그래서 보통은 시작점이 되는 m3u 혹은 m3u8 파일 내부에 있는 ts 경로에 요청하여 받은 프레임들을 합쳐 영상을 만들어낸다.
ffmpeg를 사용하면 m3u8 파일 안에 있는 ts파일 경로를 참조하여 하나씩 가져온뒤 프레임단위로 이어붙이는 식으로 저장할 수 있다.

Windows

ffmpeg.exe 파일을 받고 아래에 기록된 Linux용 방식을 그대로 사용해도 됨.

Linux

다운로드에 필요한 m3u8 url을 획득하고 ffmpeg를 사용하여 영상을 내려받을 수 있음.

ffmpeg 설치

sudo apt-get update
sudo apt-get install ffmpeg

ffmpeg로 m3u8 미디어 내려받기

ffmpeg -i ${M3U8_URL} -bsf:a aac_adtstoasc -c copy ${SAVE_FILE_PATH}

생각보다 간단히 영상을 내려받을 수 있음.
ffmpeg 사용법과 기타 옵션들은 여기서 다룰 내용이 아니기 때문에 공식문서 링크로 대체함.

내가 스트리밍 할 순 없을까?

원래 목적이었던 다운로드 문제는 해결되었다.
다운로드 받으려고 공부하다보니 이제는 내가 영상을 스트리밍 할 수는 없는걸까? 라는 생각이 들었다.

영상 분할하기

영상을 저장하기 위해 분할된 파일을 받아서 합친것을 생각해보면
가지고 있던 영상파일이나 비디오장치로 수집하고있는 영상정보를 송출하기위해서는
일정 크기로 쪼개주고 m3u8형태의 파일도 만들어줘야 하는데
이 작업은 ffmpeg를 사용하면 쉽게 할 수 있다.

미디어파일 입력

ffmpeg -i jordy.mp4 -profile:v baseline -level 3.0 -s 1920x1080 -start_number 0 -hls_list_size 5 -f hls index.m3u8

진행과정

ffmpeg version 3.4.6-0ubuntu0.18.04.1 Copyright (c) 2000-2019 the FFmpeg developers
...
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'jordy.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf57.83.100
  Duration: 00:00:22.57, start: 0.000000, bitrate: 4704 kb/s
    Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709/unknown/bt709), 1920x1080 [SAR 1:1 DAR 16:9], 4538 kb/s, 29.97 fps, 29.97 tbr, 30k tbn, 59.94 tbc (default)
    Metadata:
      handler_name    : VideoHandler
    Stream #0:1(und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 160 kb/s (default)
    Metadata:
      handler_name    : SoundHandler
Stream mapping:
  Stream #0:0 -> #0:0 (h264 (native) -> h264 (libx264))
  Stream #0:1 -> #0:1 (aac (native) -> aac (native))
Press [q] to stop, [?] for help
[libx264 @ 0x5572fd7e1320] using SAR=1/1
[libx264 @ 0x5572fd7e1320] frame MB size (120x68) > level limit (1620)
[libx264 @ 0x5572fd7e1320] DPB size (1 frames, 8160 mbs) > level limit (0 frames, 8100 mbs)
[libx264 @ 0x5572fd7e1320] MB rate (244555) > level limit (40500)
[libx264 @ 0x5572fd7e1320] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2
[libx264 @ 0x5572fd7e1320] profile Constrained Baseline, level 3.0
[libx264 @ 0x5572fd7e1320] 264 - core 152 r2854 e9a5903 - H.264/MPEG-4 AVC codec - Copyleft 2003-2017 - http://www.videolan.org/x264.html - options: cabac=0 ref=1 deblock=1:0:0 analyse=0x1:0x111 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=0 me_range=16 chroma_me=1 trellis=1 8x8dct=0 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=6 lookahead_threads=1 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=0 weightp=0 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00
[hls @ 0x5572fd7d12c0] Opening 'index0.ts' for writing
Output #0, hls, to 'index.m3u8':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Lavf57.83.100
    Stream #0:0(und): Video: h264 (libx264), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], q=-1--1, 29.97 fps, 90k tbn, 29.97 tbc (default)
    Metadata:
      handler_name    : VideoHandler
      encoder         : Lavc57.107.100 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: -1
    Stream #0:1(und): Audio: aac (LC), 48000 Hz, stereo, fltp, 128 kb/s (default)
    Metadata:
      handler_name    : SoundHandler
      encoder         : Lavc57.107.100 aac
[hls @ 0x5572fd7d12c0] Opening 'index1.ts' for writingitrate=N/A speed=2.26x    
[hls @ 0x5572fd7d12c0] Opening 'index.m3u8.tmp' for writing
[hls @ 0x5572fd7d12c0] Opening 'index2.ts' for writingitrate=N/A speed=1.99x    
[hls @ 0x5572fd7d12c0] Opening 'index.m3u8.tmp' for writing
[hls @ 0x5572fd7d12c0] Opening 'index3.ts' for writingitrate=N/A speed=1.92x    
[hls @ 0x5572fd7d12c0] Opening 'index.m3u8.tmp' for writing
[hls @ 0x5572fd7d12c0] Opening 'index.m3u8.tmp' for writing
frame=  676 fps= 55 q=-1.0 Lsize=N/A time=00:00:22.54 bitrate=N/A speed=1.84x    
video:15890kB audio:353kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown
[libx264 @ 0x5572fd7e1320] frame I:4     Avg QP:18.79  size:109659
[libx264 @ 0x5572fd7e1320] frame P:672   Avg QP:22.63  size: 23560
[libx264 @ 0x5572fd7e1320] mb I  I16..4: 46.5%  0.0% 53.5%
[libx264 @ 0x5572fd7e1320] mb P  I16..4:  9.5%  0.0%  4.1%  P16..4: 33.0%  9.1%  1.9%  0.0%  0.0%    skip:42.5%
[libx264 @ 0x5572fd7e1320] coded y,uvDC,uvAC intra: 21.7% 52.0% 2.6% inter: 10.6% 17.0% 0.0%
[libx264 @ 0x5572fd7e1320] i16 v,h,dc,p: 42% 26% 16% 17%
[libx264 @ 0x5572fd7e1320] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 20% 20% 22%  4%  9%  6% 10%  4%  4%
[libx264 @ 0x5572fd7e1320] i8c dc,h,v,p: 56% 20% 19%  4%
[libx264 @ 0x5572fd7e1320] kb/s:5770.86
[aac @ 0x5572fd7c0400] Qavg: 349.481

파일 확인

(base) d3fau1t@d3fau1t-XH57:~/DRUM/hls_test$ ls
index.m3u8  index0.ts  index1.ts  index2.ts  index3.ts  jordy.mp4
(base) d3fau1t@d3fau1t-XH57:~/DRUM/hls_test$ cat index.m3u8 
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:9
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:8.363000,
index0.ts
#EXTINF:8.341667,
index1.ts
#EXTINF:5.372033,
index2.ts
#EXTINF:0.500500,
index3.ts
#EXT-X-ENDLIST
(base) d3fau1t@d3fau1t-XH57:~/DRUM/hls_test$

비디오 장치 입력

ffmpeg -f /dev/video0 -level 3.0 -s 640x480 -start_number 0 -hls_list_size 3 -f index.m3u8

픽셀 포맷은 지정하지 않아도 알아서 잡아준다.

파일 확인

(base) d3fau1t@d3fau1t-XH57:~/DRUM/hls_test/video$ ls
index.m3u8  index0.ts  index1.ts  index2.ts  index3.ts
(base) d3fau1t@d3fau1t-XH57:~/DRUM/hls_test/video$ cat index.m3u8 
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:9
#EXT-X-MEDIA-SEQUENCE:1
#EXTINF:8.333333,
index1.ts
#EXTINF:8.333333,
index2.ts
#EXTINF:5.766667,
index3.ts
(base) d3fau1t@d3fau1t-XH57:~/DRUM/hls_test/video$

입력 소스만 다르고 생성되는 파일 종류는 같다는걸 알 수 있음.

시퀀스? : EXT-X-MEDIA-SEQUENCE

m3u8파일을 열어보면 조금 이상한 점을 느꼈을 것이다.
시간이 지남에따라 ts파일경로와 영상재생정보가 기록된다고 알고있는데, 여기까지의 내용대로라면 처음부터 끝까지 전체 정보가 저장되어있어야한다.
하지만 직접 보면서 일정량의 정보만 저장되어있는게 이상하게 느껴질 수도 있다.

비디오 장치를 입력으로 사용할때 아래와 같은 커맨드를 사용했다.

ffmpeg -f /dev/video0 -level 3.0 -s 640x480 -start_number 0 -hls_list_size 3 -f index.m3u8

시퀀스 개념을 알기위해 -hls_list_size 옵션을 3으로 줬던것임.

m3u8 파일의 #EXT-X-MEDIA-SEQUENCE 값이 변경됨
파일 경로도 변경됨.

한 시퀀스를 넘길 때마다 새로운 ts파일 정보가 들어오면서 오래된 ts파일은 목록에서 빠지게된다.

스트리밍하기

영상 분할을 마쳤으니 거의 끝났다.
분할된 파일을 서비스 할 수 있는 웹 서버와 플레이어를 포함한 웹 페이지가 필요하다.

그러기 전에 대부분의 스트리밍 서비스가 어떤식으로 시스템을 구성하였는지 한 번은 보는게 좋을 것 같다.

시스템 구성

시스템 구성은 서비스마다 다르겠지만 대부분 아래와 같이 작동한다.

  1. 클라이언트가 웹 서버에 스트리밍 컨텐츠가 있는 페이지 요청
  2. 웹서버는 클라이언트가 요청한 페이지를 전달
  3. 클라이언트가 전달받은 페이지의 플레이어가 파일(미디어) 서버에 m3u8 파일요청
  4. 플레이어는 m3u8을 참고하여 미디어 파일 재생
    아래는 몇 가지 구성 예시에 대해 추가로 기록한 내용이다.

한 곳에서 모든걸 처리하는경우

위 그림은 웹 서버와 파일 서버를 같은 시스템에 올려놓고 서비스 하는 경우를 간략히 표현한 것이다.

소규모 스트리밍 서비스를 위한 것이라면 단일 시스템에 서비스를 구축하여 시스템 구축 비용도 줄이고 관리도 어렵지 않을테니 나쁘지 않다고 생각한다.
하지만 한 곳에서 여러 서비스를 하거나 트래픽이 몰리게 될 경우 과부하가 일어나거나 서비스를 제대로 못 할 수도 있다.

웹 서버와 미디어 서버를 분리한 경우

위 그림은 웹 서버와 파일 서버를 따로 두고 서비스 하는 경우를 간략히 표현한 것이다.

어느정도 규모가 있는 스트리밍 서비스를 위해선 이전 사례와 같이 단일 시스템에 서비스를 구축하면 금방 뻗어버릴 수 있다.
최소한 이런식으로 서비스를 분리시켜놓는다면 부하가 분산되어 뻗을 확률이 줄어든다.
구축하는 비용은 좀 더 들어가더라도 안정적일 것이다.

한 시스템에서 도커같은거 써서 컨테이너로 여러 가상 시스템을 만들어놓고 한다거나
Twitch처럼 파일(미디어) 서버를 CDN에 따로 올려놓는다거나 방법은 다양해보인다.
개발자도구 열어서 플레이어가 재생하는 미디어파일이 어디서 오고있는지 확인해보는 것도 좋을듯하다.

간단하게: http.server

빠르게 테스트해보기 위해 http.server 모듈을 사용하면 좋을 것 같다.
아래의 내용은 Python이나 Node.js가 지원하는 http.server를 사용하는 간단한 트릭이다.
예시는 Python을 사용함.

이 방법은 Production 목적에 부적합하기에 필요하다면 직접 코드를 짜는 방향도 생각해봐야함.

스트리밍 서비스 시작

아래와 같이 스크립트를 작성하여 실행만 하면됨.

#!/bin/bash
# 포트 번호 지정가능. 기본 8000, 8086으로 변경하여 사용
python3 -m http.server 8086 &

# 만약 localhost의 접근만 허용하려면
#python3 -m http.server --bind 127.0.0.1

# HLS를 위해 카메라 장치로부터 영상 획득
ffmpeg -i /dev/video0 -level 3.0 -s 640x480 -start_number 0 -hls_time 10 -hls_list_size 10 -f hls index.m3u8 &

스트리밍 되고있는 영상 보기

웹페이지에 서비스 주소를 치고 들어가서 m3u8파일을 클릭하면 됨.

실시간으로 획득중인 영상을 확인할 수 있다. 재생시간에 비례해 주기적으로 받는 m3u8과 ts파일을 볼 수 있다.

하는김에 간단한 html 페이지 하나 만들어서 띄워보려고한다.
video.js 가져다가 플레이어 삽입한 페이지 만들어주고 index.html이라는 이름으로 서비스되고 있는 디렉터리에 넣어주면 된다.

플레이어 페이지 만들고 접속
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8 />
<title>videojs-contrib-hls embed</title>
  <link href="https://unpkg.com/video.js/dist/video-js.css" rel="stylesheet">
  <script src="https://unpkg.com/video.js/dist/video.js"></script>
  <script src="https://unpkg.com/videojs-contrib-hls/dist/videojs-contrib-hls.js"></script>
</head>
<body>
  <h1>Nobody can't stop Jordy</h1>
  <video id="jordy" class="video-js vjs-default-skin" controls preload="auto" width="640" height="480"
  data-setup='{}'>
  </video>

  <script>
    const player = videojs('jordy', {
      autoplay: true
    });
    player.src({
      src: 'http://localhost:8080/index.m3u8',
      type: 'application/x-mpegURL'
    });
  </script>
</body>
</html>

스트리밍 서비스 종료

#!/bin/bash
sudo pkill -f ffmpeg
sudo pkill -f http.server

이 스크립트를 실행했을 때, 같은 이름의 서비스가 여러개 실행중일 경우 모두 종료됩니다.

스트리밍 되고있는 영상을 받아보고, 직접 영상을 스트리밍도 해보면서
HTTP Live Streaming에 대해 알아보는 글을 모두 읽었다.

Ref

https://en.wikipedia.org/wiki/HTTP_Live_Streaming
https://cs.wikipedia.org/wiki/HTTP_Live_Streaming
https://ko.wikipedia.org/wiki/M3U
https://ffmpeg.org/ffmpeg.html

profile
웹 백엔드 합니다.

1개의 댓글

comment-user-thumbnail
2022년 7월 14일

진짜 집요하게 파고들며 공부하시네요.
잘 보고갑니다.

답글 달기