Skip to main content

[podoR] 4. 백엔드 스트리밍 구조 및 흐름 정리하기

서론

최근 podoR에 추가한 스트리밍 기능에 대해, 대략적인 구조와 그 흐름을 정리하고자 한다.

아키텍처

송출자의 스트리밍부터, 이를 서버에서 처리하고 수신자에게 전달하기 까지의 흐름을 구성하는 요소는 다섯가지이다. streaming-architecture

  1. 송출자(Host) PC
    • OBS Studio등의 프로그램을 이용해, RTMP 프로토콜로 영상을 전송한다.
  2. MediaMTX 컨테이너
    • 송출자의 RTMP 데이터를 받는 미디어 서버. 송출이 시작될 때, 끝날 때 스크립트를 실행한다.
  3. ffmpeg
    • MediaMTX 컨테이너와 Spring 컨테이너 두 곳에서 사용된다.
    • MediaMTX 컨테이너에서는 RTMP 데이터를 HLS 파일로 바꾼다.
    • Spring 컨테이너에서는 HLS 조각(.ts)을 VOD(mp4)로 조합한다.
  4. Spring 컨테이너
    • 스트림 키, 방송 상태, 관람 권한 등 스트리밍 서비스과 관련된 요소들을 처리한다.
  5. nginx 컨테이너
    • HLS 파일을 정적 파일로 제공하되, 요청마다 사용자의 권한 을 Spring 컨테이너로부터 확인 받는다.

구성을 보면 알 수 있듯 영상 데이터는 Spring 서비스를 거치지 않으며, 송출 허가 요청, 영상 파일 접근 권한 확인 등의 짧은 HTTP 요청만 처리한다.
(다만, 스트림이 종료된 뒤에는 이를 감지해 디스크에 남은 HLS 조각들을 합쳐 VOD를 만든다.)

흐름

1. 스트림 키 발급

호스트가 스트림 관리 페이지(/host/events/{eventId}/stream)에 진입할 경우, 스트림 키 조회 API(GET /host/events/{eventId}/stream)가 호출된다.
이 API는 해당 이벤트의 소유자(HOST)와 관리자(ADMIN)만 호출할 수 있도록 설정되어 있다.

 // StreamHostController
 @GetMapping("/stream-key")
 @PreAuthorize("@eventSecurity.isOwner(#eventId) or hasRole('ADMIN')")
 public ResponseEntity<StreamKeyResponse> getStreamKey(@PathVariable String eventId) {
	 ...
 }

만약 키가 아직 없다면, 즉석에서 만들어서 event.stream_key 컬럼으로 저장한다.
(JPA의 더티 체킹으로 인해 자동으로 update 쿼리 호출)

private static String newKey() {
	byte[] bytes = new byte[32];
	RANDOM.nextBytes(bytes);
	return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

키 값은 의미없는 Base64 포맷의 43자짜리 키로, 발급해도 DB에 등록되는 것 말고는 별도의 이벤트는 발생하지 않는다.
MediaMTX 컨테이너는 "^live/(.+)$" 경로(즉, live/*)에 대해 받을 준비만 해놓고, 실제로 요청이 들어오면 /scripts/on-ready.sh에 작성된 로직대로 Spring 서버에 ‘이 스트림 키가 유효한가’를 질의한다. (POST /api/internal/stream/started)

정확히는, 해당 스트림 키를 갖는 Event를 찾는 질의를 통해 유효성을 검증함.
StreamLifecycleService.started에서 findByKey를 호출.

# on-ready.sh
KEY="${MTX_PATH#live/}"
# -f: 응답 코드 400인 경우 오류 코드 22와 함께 응답 본문이 전혀 출력되지 않도록 설정
# -s: silent mode. 진행률 표시기, 참고 메시지, 경고 등을 표시하지 않음
# -S: 오류 표시. -s와 함께 사용시 curl이 실패할 경우 오류 메시지 표시
RESP=$(curl -fsS --retry 10 --retry-delay 3 --retry-all-errors -X POST \
  -H "X-Internal-Token: ${STREAM_INTERNAL_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{\"streamKey\":\"${KEY}\"}" \
  "${SPRING_URL}/api/internal/stream/started")
# 위 경로로 요청을 보낸 결과를 표준 출력으로 출력한 뒤, 그 값을 인자로 받아
# jq(명령줄 기반의 JSON 프로세서)로 JSON 문자열 대신 표준 출력으로 직접 출력되도록 함
# 그리고 그 중 키 [outputDir]의 값을 OUT이라는 변수에 할당
OUT=$(printf '%s' "$RESP" | jq -r .outputDir)
# -p: 경로에 있는 상위 디렉터리가 없는 경우 해당 디렉터리와 함께 상위 디렉터리를 생성하고, 디렉터리가 이미 존재해도 오류 발생시키지 않음
mkdir -p "$OUT/src" "$OUT/480p"

키는 노출될 경우, POST /stream-key/reissue API를 통해 재발급 할 수 있으며, 방송중일 경우(LIVE | INTERRUPTED) 이러한 재발급은 409 Conflict로 실패한다.

‘동일한 스트림 키로 송출 중단 후 재송출’ 동작을 지원하기 위해서는 기존 키로 재접속 할 수 있어야 하기 때문

outputDir은 StreamProperties에 설정된 mediaRoot + /hls/ + event.getEventId() + / + sessionNo

2. 호스트가 송출한 미디어 수신하기

호스트 측에서 OBS Studio와 같은 툴을 이용해 스트림을 시작하면, 프로그램은 rtmp://<서버 주소>:1935/live/<스트림 키>로 접속한다.

1935번 포트는 전통적으로 Adobe Flash를 비롯한 애플리케이션에서 RTMP 통신을 위해 사용됨

이 통신은 nginx를 거치지 않고 바로 MediaMTX 컨테이너로 전송된다.

# mediamtx.yaml

# RTMP 수신만 받도록 하고, 나머지는 비활성화한다.
rtmp: yes
rtsp: no
hls: no
webrtc: no
srt: no
moq: no # v1.21부터 기본으로 켜지는 MoQ 서버는 사용하지 않을 예정이므로 비활성화

authMethod: http          # 허가 판단을 HTTP 로 밖에 맡긴다
authHTTPExclude:
  - action: api
  - action: metrics
  - action: pprof

그리고 HLS을 제공하는 역할은 nginx에서 수행하도록 할 것이기 때문에, MediaMTX에서는 클라이언트측으로 출력하지 않고, RTMP 수신만 하도록 설정했다.
또한, 인증을 활성화하되(authMethod), 외부 HTTP 서버(즉, Spring 서버)를 통해 진행하도록 했다.
따라서, MediaMTX에서는 새 연결 요청이 들어올 때 마다 authHTTPAddress로 설정된 주소로 인증을 요청한다. 참고 - mediaMTX Configuration

# ...
      MTX_AUTHHTTPADDRESS: http://spring:8080/api/internal/stream/publish-auth?token=${STREAM_INTERNAL_TOKEN}

이때, 이 authHTTPAddress는 docker-compsoe.yml에서 MTX_AUTHHTTPADDRESS라는 환경변수로 주입한다.

{ "action": "publish", "path": "live/<스트림 키>", "ip": "100.x.x.x", "protocol": "rtmp", ... }

해당 URL로 MediaMTX는 다음과 같은 포맷(참고)의 JSON을 POST 요청으로 전송하며, 응답 코드가 2XX이면 허가, 그 외에는 연결을 끊는다.

// StreamInternalController.java
    @PostMapping("/publish-auth")
    public ResponseEntity<Void> publishAuth(@RequestBody PublishAuthRequest request) {
        boolean allowed = lifecycleService.authorize(request, LocalDateTime.now());
        return ResponseEntity.status(allowed ? HttpStatus.OK : HttpStatus.UNAUTHORIZED).build();
    }

// StreamLifecycleService.java
    @Transactional(readOnly = true)
    public boolean authorize(PublishAuthRequest req, LocalDateTime now) {
        if ("read".equals(req.action())) {
            return LOOPBACK.contains(req.ip());
        }
        if (!"publish".equals(req.action())) {
            return false;
        }
		// (1) 회차 조회
        Optional<Event> found = keyOf(req.path()).flatMap(eventRepository::findByStreamKeyAndDeletedDateIsNull);
        if (found.isEmpty()) {
            return false;
        }
        Event event = found.get();
		// (2) 스트리밍 상태 검사(PUBLISHABLE = `SCHEDULED`, `LIVE`, `INTERRUPTED`로 이뤄진 EnumSet)
        return PUBLISHABLE.contains(event.effectiveStreamStatus())
		// (3) 송출 가능 시간 정책 검사
                && publishPolicy.allows(event, now)
                && eventRepository.countByStreamStatusInAndSeqNot(ACTIVE, event.getSeq()) < props.maxConcurrent();
    }

그리고 Spring 서버의 POST api/internal/stream/publish-auth에서는 4가지 요소를 모두 만족할 경우만 200 응답을 돌려준다.

  1. json의 path에서 live/를 떼고 남은 스트림 키로 삭제되지 않은 회차가 있는지 (findByStreamKeyAndDeletedDateIsNull)
  2. 해당 회차의 스트리밍 상태가 SCHEDULED, LIVE, INTERRUPTED 중 하나인지
  3. 송출 가능 시간 정책(StreamPublishPolicy)을 통과하는지

현재 구현에서는 항상 통과하도록 설정함

  1. 해당 회차를 제외하고 지금 송출중인 회자의 수가 stream.max-concurrent(props.maxConcurrent) 미만인지

현재 테스트중인 서버에서는 성능이 좋지 않아 동시 방송을 하나로 제한함

또한, MediaMTX 이외의 외부에서 /api/internal로 접근하지 못하도록 nginx에서 404로 막도록 처리하며, Spring의 InternalTokenInterceptor를 통해 요청이 진짜 MediaMTX로부터 온 것인지 검사하기 위한 값(X-Internal-Token)을 검사토록 함.

이를 통해 외부로부터의 접속 차단은 물론, 내부 도커 네트워크의 컨테이너가 임의로 접근하는 경우도 차단함

3. 영상 처리 - MediaMTX 훅 스크립트

# mediamtx.yml
paths:
  # 송출자의 {rtmp://<host>/live, 스트림 키 {key}} -> path로 변환시 `live/{key}`
  "~^live/(.+)$":
    # 송출이 시작되면 Spring 서버에 세션을 만들고 `ffmpeg`를 이용해 HLS 출력을  생성한다(스크립트가 ffmpeg 로 exec).
    # (1.21 에서 runOnReady/runOnNotReady 가 runOnAvailable/runOnUnavailable 로 이름이 바뀌었다)
    runOnAvailable: /scripts/on-ready.sh
    runOnAvailableRestart: no
    # 송출이 끊기면 Spring 에 알린다(INTERRUPTED, 유예 시간 뒤 종료 확정).
    runOnUnavailable: /scripts/on-not-ready.sh

허가를 받은 받은 송출이 들어오기 시작하면, MediaMTX는 해당 path를 사용 가능(available)로 표시한 뒤, 설정된 훅을 실행한다.

# on-ready.sh
KEY="${MTX_PATH#live/}"
# -f: 응답 코드 400인 경우 오류 코드 22와 함께 응답 본문이 전혀 출력되지 않도록 설정
# -s: silent mode. 진행률 표시기, 참고 메시지, 경고 등을 표시하지 않음
# -S: 오류 표시. -s와 함께 사용시 curl이 실패할 경우 오류 메시지 표시
# Spring 서버가 재기동중 일 수 있는 상황을 고려해 3초 간격으로 10번까지 재시도한다.
RESP=$(curl -fsS --retry 10 --retry-delay 3 --retry-all-errors -X POST \
  -H "X-Internal-Token: ${STREAM_INTERNAL_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{\"streamKey\":\"${KEY}\"}" \
  "${SPRING_URL}/api/internal/stream/started")
# 위 경로로 요청을 보낸 결과를 표준 출력으로 출력한 뒤, 그 값을 인자로 받아
# jq(명령줄 기반의 JSON 프로세서)로 JSON 문자열 대신 표준 출력으로 직접 출력되도록 함
# 그리고 그 중 키 [outputDir]의 값을 OUT이라는 변수에 할당
OUT=$(printf '%s' "$RESP" | jq -r .outputDir)
# -p: 경로에 있는 상위 디렉터리가 없는 경우 해당 디렉터리와 함께 상위 디렉터리를 생성하고, 디렉터리가 이미 존재해도 오류 발생시키지 않음
mkdir -p "$OUT/src" "$OUT/480p"
  1. Spring 서버에 스트리밍 시작을 알리고, 출력 폴더를 받아온다.
        public StreamStartedResponse started(String streamKey, LocalDateTime now) {
        Event event = findByKey(streamKey);
    	// 1. 방송 상태를 `LIVE`로 바꾼다. (조건부 상태 전이)
        int changed = eventRepository.transitStreamStatus(event.getSeq(), STARTABLE, true, StreamStatus.LIVE);
        if (changed == 0) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "송출을 시작할 수 없는 상태입니다.");
        }
    	// 2. 직전 세션이 있는지 조회한다.
        Optional<StreamSession> previous = sessionRepository.findTopByEvent_SeqOrderBySessionNoDesc(event.getSeq());
        // 만약 있다면, 닫는다. (`stopped`가 유실된 이전 세션은 지금 끊긴 것으로 닫는다)
        previous.ifPresent(s -> s.end(now));
    	// 3. stream_session에 새 행(session_no의 경우, 직전의 값에 1을 추가한 값)을 만든다.
        int sessionNo = previous.map(s -> s.getSessionNo() + 1).orElse(1);
        sessionRepository.save(StreamSession.start(eventRepository.getReferenceById(event.getSeq()), sessionNo, now));
    	// 4. 출력 폴더 `/media/hls/{eventId}/{sessionNo}`를 응답으로 돌려준다.
        String outputDir = props.mediaRoot() + "/hls/" + event.getEventId() + "/" + sessionNo;
        return new StreamStartedResponse(event.getEventId(), sessionNo, outputDir);
    }
    
    • POST /api/internal/stream/started에 위와같이 streamKey를 전송하면, Spring 서버는 하나의 트랜잭션 안에서 다음과 같이 로직을 처리한다.
  2. 인코더를 선택한다.
    # Intel GPU(i915/xe(Tigerlake 이후 플랫폼의 그래픽 드라이버)) 렌더 노드를 찾는다. 다른 GPU(예: NVIDIA)가 함께 있으면 renderD128 이 Intel 이
    # 아닐 수 있고, 번호는 부팅마다 동적할당되기 때문에, 바뀔 수 있다. 사용자가 `STREAM_VAAPI_DEVICE`를 주면 그 값을 쓴다.
    find_intel_render_node() {
      for node in /dev/dri/renderD*; do
        driver=$(basename "$(readlink -f "/sys/class/drm/$(basename "$node")/device/driver")" 2>/dev/null || true)
        if [ "$driver" = "i915" ] || [ "$driver" = "xe" ]; then
          echo "$node"
          return 0
        fi
      done
      return 1
    }
    
    # 인코더
    if [ "${STREAM_ENCODER:-vaapi}" = "vaapi" ]; then DEVICE="${STREAM_VAAPI_DEVICE:-$(find_intel_render_node || echo /dev/dri/renderD128)}"
      echo "on-ready: VAAPI device $DEVICE" >&2
      HW="-vaapi_device $DEVICE"
      V1="-filter:v:1 format=nv12,hwupload,scale_vaapi=w=-2:h=480 -c:v:1 h264_vaapi"
    else
      HW=""
      V1="-filter:v:1 scale=-2:480 -c:v:1 libx264 -preset veryfast"
    fi
    
    • 현재 사용중인 서버(노트북)의 경우, 내장 그래픽으로 Interl UHD Graphics 630이 탑재되어 있어, 인텔의 하드웨어 동영상 가속 라이브러리인 VAAPI(Video Acceleration API)를 사용하여 내장된 Intel Quick Sync Video 기능을 사용한다.
      • 가용한 인텔 내장 GPU가 있는 경우, h264_vaapi, 그렇지 않은 경우 libx264 를 이용해 인코딩을 진행한다. (둘 다 H.264 인코딩)
      • 현재 서비스에서는 원본화질, 480p 두 가지의 화질을 제공하려하기 때문에, 원본영상을 480p로 인코딩하는데에 GPU를 사용한다
  3. 비디오를 생성한다.
    # -hls_list_size 0 + event: 조각을 지우지 않는다(처음부터 되감기 가능, VOD 재료).
    # shellcheck disable=SC2086
    exec ffmpeg -hide_banner -loglevel warning $HW -i "$IN" \
      -map 0:v:0 -map 0:a:0 -map 0:v:0 -map 0:a:0 \
      -c:v:0 copy -c:a:0 copy \
      $V1 -b:v:1 1M -maxrate:v:1 1200k -bufsize:v:1 2M -g:v:1 60 \
      -c:a:1 aac -b:a:1 96k \
      -f hls -hls_time 2 -hls_list_size 0 -hls_playlist_type event -hls_flags independent_segments \
      -var_stream_map "v:0,a:0,name:src v:1,a:1,name:480p" \
      -master_pl_name master.m3u8 \
      -hls_segment_filename "$OUT/%v/%05d.ts" "$OUT/%v/index.m3u8"
    
    • 원본 영상(rtmp://127.0.0.1:1935/live/<키>)을 ffmpeg 에 입력으로 제공하여, 원본 화질(v0, a0)과 480p(v1, a1) 두가지 출력을 만들어 낸다.
      • 원본(v0, a0)은 copy로 디코딩/재인코딩 없이 그대로 복사한다.
      • 저화질 버전(v1, a1)은 비디오의 경우 디코딩 후 480p로 재인코딩(비트레이트 1M ), 오디오의 경우 AAC 96k로 재인코딩한다.
    • 이후 hls 포맷으로 출력을 생성하되, 원본은 .../{sessionNo}/src/에, 저화질 버전은 .../{sessionNo}/480p/에 저장한다.
    • 또한, .../{sessionNo}/에 두 화질 목록을 갖는 master.m3u8을 생성하는데, 수신자는 이를 받아 hls.js에서 네트워크 상태에 따라 둘을 오가도록 한다.
      • 화질 이동시 끊김을 방지하기 위해, 키프레임 간격을 2초로 맞추도록 규칙을 세웠다. (즉, 2초마다 화질별 폴더에 .ts 파일이 추가된다.)
        • 480p의 경우 GOP(Group Of Pictures)가 60(30fps 기준 2초)이고, 원본은 복사만 하고 있으므로 송출시에 호스트에게 키프레임 간격을 2초로 설정할 것을 안내한다.
    • 또한, hls 조각 파일을 지우지 않고 재생 목록에 계속 추가하며(-hls_list_size 0), 조각은 재생 목록의 맨 뒤에만 추가하고 기존 조각은 절대 삭제하지 않도록 한다.(-hls_playlist_type event)
      • 이를 통해 사용자는 방송 중에도 이전으로 되감을 수 있게되고, 조각들이 그대로 남아있으므로 추후 VOD를 만드는데에 재료로 사용할 수 있다.
    • 그리고, exec를 통해 ffmpeg을 실행함으로써, MediaMTX의 송출이 끝났을 때 훅 프로세스(on-ready.sh를 실행하고 있는 프로세스)에게 보내는 종료 신호가 ffmpeg에도 닿도록한다.
      • 이렇게 하지 않을 경우, ffmpeg의 종료는 MediaMTX-ffmpeg간 RTMP 연결이 닫히는 시점에 의존하게 된다. (따라서 직관적으로 ffmpeg에 종료 신호가 직접 닿게 exec를 이용해 프로세스를 띄운다.)

4. 라이브 시청

HLS 포맷의 경우, 반복적으로 정적 파일을 서버에서 받는 구조다 보니 nginx 하나만으로 이를 제공하도록 한다.

    @GetMapping("/events/{eventId}/stream")
    public ResponseEntity<StreamViewResponse> view(@PathVariable String eventId, Authentication authentication) {
        Long userSeq = (Long) authentication.getPrincipal();
		// 사용자가 해당 공연 회차에 대해 결제한 좌석이 있는지 조회한 뒤, 가능하다면 관람에 필요한 정보 담은 DTO 생성
        StreamViewResponse body = viewService.view(userSeq, eventId);
        ResponseCookie cookie = ResponseCookie.from(COOKIE, signer.sign(userSeq, eventId, Instant.now().plus(COOKIE_TTL)))
                .path("/media/").httpOnly(true).secure(true).sameSite("Strict").maxAge(COOKIE_TTL).build();
        return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(body);
    }

관람 페이지(app/events/[eventId]/stream)에서는 5초마다 관람 상태(GET /api/events/{eventId}/stream)를 받아온다.

TODO: 지금은 소규모 서비스를 상정했기 때문에 큰 부하는 아니지만, 사용자별로 5초마다 DB 요청이 발생하게 되므로 추후 수정이 필요하다.

{ "status": "LIVE", "vodStatus": "NONE" ,
  "liveUrl": "/media/hls/{eventId}/2/master.m3u8", "vodUrl": null }

해당 API에서는 사용자가 해당 회차에 결제한 좌석이 있는지 확인하고, 없다면 403을, 있다면 위와 같은 응답을 돌려준다.

스트리밍이 종료됐다면, 위와 반대로 vodStatus, vodUrl이 유효한 값을 가짐

Set-Cookie: podor_media=<payload>.<HMAC>; Path=/media/; Max-Age=600; HttpOnly; Secure; SameSite=Strict

또한, 위와 함께 응답 헤더에 SET-COOKIE로 관람 쿠키 podor_media을 함께 담아 보낸다.

이 쿠키는 /media/에 한정되어 있으며, 수명이 10분이긴 하지만 매 폴링(/api/events/{eventId}/stream)마다 갱신된다.

    # HLS(/media/hls/{eventId}/{sessionNo}/…) · VOD(/media/vod/{eventId}.mp4)
    location /media/ {
        set $media_uri $uri;   # rewrite 단계(auth_request보다 먼저 진행함). 정규화된 경로를 하위 요청에 넘기기 위해 보관한다.
        auth_request /_media_auth; # 정적 파일들을 제공하기 전, Spring 서버에 묻는다.
        alias /srv/media/; # 위 인증을 통과할 경우, `media` 볼륨의 파일을 그대로 제공한다.
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t                    ts;
            video/mp4                     mp4;
        }
        add_header Cache-Control $media_cache_control; # .m3u8 파일은 계속 갱신되므로 no-cache로 캐싱하지 않는다.
    }

nginx에서는 /media/에 위치한 파일들(.m3u8, .ts)을 내주기 전, 요청에 대한 인증을 수행한다.

  • Spring 서버의 GET /api/internal/media/authorize에 원래 경로(X-Original-URI)와 관람 쿠키를 담아 보낸다.
    • 해당 API에서는 쿠키 서명 및 수명 검사, URI 포맷(/media/hls/{eventId}/… 또는 /media/vod/{eventId}.mp4) 검사, URI의 eventId와 쿠키의 eventId 일치 여부 검사를 수행한다.
      • 즉, 비교적 짧은 시간내에 빈번한 조각/재생목록 요청에서는 DB를 거치지않고 쿠키 서명만 검사하도록하고, 덜 빈번한 관람 상태 요청에서 DB까지 거친 검증을 수행하도록 한다.

프론트엔드 측의 hls.js는 인가를 통과해 /meida/ 내에 접근이 가능해지면, 받아온 조각/재생목록 파일을 이용해 영상을 재생한다. hls.js가 이를 처리하는 과정은 다음과 같다.

  1. 먼저, master.m3u8을 받아 두 화질(src, 480p)과 각 대역폭을 알게 된다.
  2. 화질을 고르고 그 index.m3u8을 받는다. 목록에 #EXT-X-ENDLIST가 없으면 “아직 끝나지 않은 라이브"로 판단한다.
  3. 라이브 끝(최신 조각)에서 조각 3개쯤 뒤부터 재생을 시작한다.
  4. 조각 길이(2초)마다 index.m3u8을 다시 받는다. ffmpeg가 그사이 추가한 조각이 목록에 나타나면 받아서 버퍼 뒤에 잇는다.
  5. 다운로드 속도를 재서 원본과 480p 사이를 자동으로 오간다(ABR, Adaptive Bitrate Streaming 참고).

5. 종료 후 VOD 생성

streaming-state 이 서비스에서 방송 상태를 4가지로 관리한다.

  • 송출 전(SCHEDULED)
  • 방송 중(LIVE)
  • 끊김/재접속 대기(INTERRUPTED)
  • 방송 종료(ENDED)

인터넷 문제 또는 OBS 등 송출 프로그램 문제로 인해 방송이 잠깐 끊길때마다 VOD가 만들어지면 곤란하기 때문에 3분(stream.interrupt-grace) 정도의 텀을 두고 호스트의 재접속을 기다린다.

runOnUnavailable: 끊김 감지

스트리밍 송출 연결이 끊겼을 때, MediaMTX는 다음 작업들을 수행한다.

  • runOnAvailable 훅의 프로세스(즉, on-ready.sh를 수행하던 프로세스가 띄운 ffmpeg 프로세스)를 종료한다. ffmpeg 프로세스는 해당 세션의 재생목록을 마무리한 뒤 종료한다.
  • runOnUnavailable 훅에 연결된 on-not-ready.sh 스크립트를 실행한다. 이 스크립트는 P[^1]OST /api/internal/stream/stopped에 요청을 보낸다.
    // StreamInternalController.java
        @PostMapping("/stopped")
        public ResponseEntity<Void> stopped(@RequestBody StreamCallbackRequest request) {
            lifecycleService.stopped(request.streamKey(), LocalDateTime.now());
            return ResponseEntity.noContent().build();
        }
    
    // StreamLifecycleService.java
    	public void stopped(String streamKey, LocalDateTime now) {
            Optional<Event> found = eventRepository.findByStreamKeyAndDeletedDateIsNull(streamKey);
            if (found.isEmpty()) {
                return;
            }
            Long seq = found.get().getSeq();
            // 유예 만료 판정(`StreamLifecycleService.endExpired`)이 마지막 세션의 `ended_at`을 보므로(이때, `eventRepository.findInterruptedSince`를 사용)
    		// 스트림 상태 갱신보다 먼저 ended_at 값을 기록한다.
    	    sessionRepository.findTopByEvent_SeqOrderBySessionNoDesc(seq)
    			.ifPresent(s -> s.end(now));
            sessionRepository.flush();
    		// 조건부 상태전이를 통해, 해당 스트림의 상태를 `INTERRUPTED`로 변경한다.
            eventRepository.transitStreamStatus(seq, List.of(StreamStatus.LIVE), false, StreamStatus.INTERRUPTED);
    
            Event after = eventRepository.findById(seq).orElseThrow();
            if (after.getStreamStatus() == StreamStatus.ENDED && after.getVodStatus() == VodStatus.NONE) {
                vodRequester.requestBuild(seq);
            }
        }
    
    • Spring 서버는 마지막 세션의 ended_at을 기록하고 상태를 LIVE → INTERRUPTED로 바꾼다.

INTERRUPTED: 방송 재개 유예 시간

INTERRUPTED 상태일 경우, stream.interrupt-grace분 동안 동일한 스트림 키로의 재송출이 허용된다.
이 경우, 위에서 설명한 "2. 호스트가 송출한 미디어 수신하기", "3. 영상 처리 - MediaMTX 훅 스크립트" 과정이 다시 이뤄진다.
stream.interrupt-grace분 안에 재송출이 이뤄지지 않을 경우, 30초마다 동작하는 StreamInterruptScheduler가 이를 정리한다.
(해당 스케줄러는 그 시점을 기준으로 lifecycleService.endExpired()를 호출한다)
만약 호스트가 직접 방송 종료버튼을 눌렀을 경우,

즉, POST /events/{eventId}/stream/end로 요청을 보내, lifecycleService.endByHost()가 호출되었을 때

유예를 기다리지 않고 바로 ENDED로 만든 뒤, VOD 생성을 요청한다.

transitStreamStatus: 조건부 스트리밍 상태 갱신

현재 코드에서 StreamStatus를 수정하는 곳은 3곳이다.

  • MediaMTX 콜백: on-not-ready.sh의 POST /api/internal/stream/stopped(StreamHostController.end()) 요청
  • 유예 스케줄러 (endExpired): StreamInterruptScheduler.endExpired()
  • 호스트의 종료 버튼: POST /events/{eventId}/stream/end 요청(StreamInternalController.stopped() 호출)

궁극적으로 위 케이스들은 모두 eventRepository.transitStreamStatus를 호출해 조건부 상태 전이를 통해 StreamStatus를 갱신한다.
이때, DB가 행 잠금으로 순서를 정해 주므로 별도의 분산 락 없이 충돌하는 두 요청 중 하나만 성공한다.

ex) 유예가 끝나는 순간 호스트가 재접속하면 스케줄러는 ENDED로, 콜백은 LIVE로 바꾸려 하는 충돌 발생

transitVodStatus: 조건부 VOD 상태 갱신

// VodService.java
    public void requestBuild(Long eventSeq) {
		// 조건부 상태 전이 시도
        Integer changed = tx.execute(s -> eventRepository.transitVodStatus(
                eventSeq, List.of(VodStatus.NONE, VodStatus.FAILED), VodStatus.PROCESSING));
		// 갱신 실패한 경우, 즉, 경쟁에서 진 후발 요청들
        if (changed == null || changed == 0) {
            return;
        }
        submitAfterCommit(eventSeq);
    }

VOD 상태도 마찬가지로, NONE/FAILED -> PROCESSING로 조건부 상태 전이를 통해 갱신에 성공한 경우에만 VOD 생성 작업을 요청한다.

이를 통해 스트리밍 종료 신호가 여러 번 도착해도, VOD는 한 번만 만든다.

VOD 생성

  1. VOD 상태 갱신에 성공한 후, 즉 커밋이 끝난 이후(afterCommit) VOD 생성 작업(VodService.build())을 수행하도록 vodExecutor에 작업을 등록
// vodService.java
	private void submitAfterCommit(Long eventSeq) {
		if (TransactionSynchronizationManager.isSynchronizationActive()) {
			TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
				@Override
				public void afterCommit() {
					executor.execute(() -> build(eventSeq));
				}
			});
		} else {
			executor.execute(() -> build(eventSeq));
		}
	}
// VodExecutorConfig.java
	/** VOD 변환 전용 스레드 풀. 디스크 I/O 가 큰 작업이라 한 번에 하나만 돌도록 설정. */
	@Bean("vodExecutor")
	public ThreadPoolTaskExecutor vodExecutor() {
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
		executor.setCorePoolSize(1);
		executor.setMaxPoolSize(1);
		executor.setQueueCapacity(100);
		executor.setThreadNamePrefix("vod-");
		executor.initialize();
		return executor;
	}
  • 이때, 만약 트랜잭션이 진행중인 경우(즉, 아직 VOD 상태 변경 커밋이 완료되지 않은 경우) TransactionSynchronizationManager에 커밋 이후 수행되는 콜백(afterCommit)을 등록한다.

TransactionSynchronizationManager: 스레드별 리소스 및 트랜잭션 동기화를 관리하는 중앙 관리자. 트랜잭션이 작업을 수행하는 동안 이와 관련된 자원(JDBC 커넥션 같은)을 하나의 컨텍스트에서 관리 한다.

  1. VOD 생성 작업 수행
// VodService.java
	public void build(Long eventSeq) {
		// 1. 해당 공연의 hls 조각 디렉토리 찾기
        Event event = eventRepository.findById(eventSeq).orElse(null);
        if (event == null) {
            return;
        }
        String eventId = event.getEventId();
        Path root = Path.of(props.mediaRoot());
        Path hlsDir = root.resolve("hls").resolve(eventId);
        try {
			// 2. 공연의 스트리밍 세션별로 `src` 폴더 위치를 List로 모으기
            List<Path> segments = collectSegments(hlsDir, sessionRepository.findAllByEvent_SeqOrderBySessionNoAsc(eventSeq));
            if (segments.isEmpty()) {
                throw new IOException("no segments under " + hlsDir);
            }
            Path vodDir = Files.createDirectories(root.resolve("vod"));
            Path tmp = vodDir.resolve(eventId + ".mp4.tmp");
			// 3. ffmpeg를 이용해 `concat demuxer` 방식으로 영상을 잇기
            ffmpegRunner.concatToMp4(segments, tmp);
			// 4. 만들어진 파일은 정해진 VOD 위치 포맷에 맞는 곳으로 이동
            Files.move(tmp, vodDir.resolve(eventId + ".mp4"),
                    StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
			// 5. VOD가 준비됐음을 event 컬럼에 조건부 상태 전이를 이용해 갱신
            markVod(eventSeq, VodStatus.READY);
			// 6. 사용한 hls 조각 디렉토리들을 삭제
            deleteRecursively(hlsDir);
        } catch (Exception e) {
            log.error("VOD build failed: event={}", eventId, e);
            markVod(eventSeq, VodStatus.FAILED);
        }
    }
  • 이후, FfmpegRunner 라는 커스텀 러너에서 ffmpeg 프로세스를 띄워 hls 조각 파일(.ts)들을 이어붙이는 concatenation 진행한다.
    • 이때, concat demuxer라는 방식을 사용하는데, 이는 파일 목록 및 지시어로 이뤄진 텍스트 파일을 읽고 그들을 마치 ‘모든 패킷이 하나로 다중화 된 것 처럼 이를 디멀티플렉싱 하는것‘처럼, 차례대로 잇는 방식이다. 공식 문서
      • 디멀티플렉서: 하나의 입력 신호를 받아, 여러 개의 출력선 중 하나를 선택해 출력하는 회로
    • 이렇게 concat할 때, 재인코딩을 수행하지 않도록 해 빠르게 작업 속도를 빠르게 한다.
      • 또한, +faststart 옵션을 주어 인덱스 역할을 하는 moov 를 앞에 두어, 영상을 전부 받기 전부터 브라우저가 재생 및 탐색을 진행할 수 있도록 한다.
  • 이어서 concat한 임시 파일을 정해진 포맷에 따른 vod 위치(/media/vod/{eventId}.mp4)로 이동시킨 뒤, 사용한 HLS 폴더들을 지운다.
// VodService.java
    /** 서버 재시작으로 PROCESSING 에 멈춘 작업을 다시 돌린다. */
    @EventListener(ApplicationReadyEvent.class)
    public void resumePending() {
        for (Event event : eventRepository.findAllByVodStatus(VodStatus.PROCESSING)) {
            Long seq = event.getSeq();
            executor.execute(() -> build(seq));
        }
    }
  • 만약 변환 도중 서버가 재시작되어 PROCESSING 상태로 남은 회차가 있을 수 있으므로, 기동 직후(ApplicationReadyEvent) 이러한 회차를 찾아 VOD 생성을 재요청한다.
  1. 시청자의 VOD 폴링 및 시청
// StreamViewer.tsx
// ...
export default function StreamViewer({ eventId }: { eventId: string }) {
  // 5초 간격으로 스트리밍 정보를 폴링하는 훅
  const { view, forbidden, error } = useStreamView(eventId);

  if (forbidden) return <Notice>예매한 관객만 볼 수 있습니다.</Notice>;
  if (!view) return <Notice>{error ? "방송 정보를 불러오지 못했습니다." : "불러오는 중..."}</Notice>;

  switch (view.status) {
    case "SCHEDULED":
      return <Notice>방송 준비중입니다.</Notice>;
    case "INTERRUPTED":
      return <Notice>방송이 잠시 중단되었습니다.</Notice>;
    case "LIVE":
      return view.liveUrl ? <LivePlayer url={view.liveUrl} /> : <Notice>방송 준비중입니다.</Notice>;
    case "ENDED":
	// 스트리밍이 종료되었으며, VOD가 준비된 경우
      if (view.vodStatus === "READY" && view.vodUrl) {
		// VOD 영상을 렌더링
        return (
          <video
            data-testid="vod-video"
            src={view.vodUrl}
            controls
            playsInline
            className="aspect-video w-full rounded-xl bg-black"
          />
        );
      }
      if (view.vodStatus === "FAILED") return <Notice>VOD 를 준비하지 못했습니다.</Notice>;
      return <Notice>VOD 준비중입니다.</Notice>;
  }
}
  • 스트리밍이 종료된 이후에도, 시청자는 주기적으로 스트리밍 상태를 폴링한다.
  • 만약, VOD가 준비된 경우, 해당 VOD URL을 src로 하는 <video> 엘리먼트를 렌더링한다.

트러블슈팅

이러한 서비스를 구현하며, 겪은 몇 가지 문제와 그 원인, 그리고 어떻게 해결했는지를 공유하려 한다.
아래 문제들은 모두 Spring/Next.js 단위 테스트에서는 잡히지 않았으며, nginx에서 Tomcat으로, 컨테이너에서 GPU로 넘어갈 때 처럼 환경의 경계를 넘어갈 때 발생했다.

HLS 생성 시 GPU 선택 문제

문제

mediaMTX.yml을 비롯해 송출/송출중단에 대응되는 스크립트들을 세팅했음에도, HLS 파일들이 만들어지지 않아 재생에 실패했다.

원인

# 이전 `on-ready.sh`
# 480p 인코더. 원본(v:0)은 재인코딩 없이 그대로 복사한다.
if [ "${STREAM_ENCODER:-vaapi}" = "vaapi" ]; then
  HW="-vaapi_device /dev/dri/renderD128"
  V1="-filter:v:1 format=nv12,hwupload,scale_vaapi=w=-2:h=480 -c:v:1 h264_vaapi"
else
  HW=""
  V1="-filter:v:1 scale=-2:480 -c:v:1 libx264 -preset veryfast"
fi

송출 스크립트(on-ready.sh)에서 GPU 노드가 올바르게 잡히지 않아 발생한 문제였다.
서버로 사용한 PC에는 내장 GPU, 외장 GPU 두 가지가 있었는데, 내장 GPU 일 것이라고 생각한 renderD128 노드가 nouveau 드라이버를 사용하는 외장 GPU였다.
이로 인해 VAAPI 초기화에 실패했고, ffmpeg 프로세스가 바로 죽어버렸다.

해결

# Intel GPU(i915) 렌더 노드를 찾는다. 다른 GPU(예: NVIDIA)가 함께 있으면 renderD128 이 Intel 이
# 아닐 수 있고, 번호는 부팅마다 바뀔 수 있다. STREAM_VAAPI_DEVICE 를 주면 그 값을 쓴다.
find_intel_render_node() {
  for node in /dev/dri/renderD*; do
    driver=$(basename "$(readlink -f "/sys/class/drm/$(basename "$node")/device/driver")" 2>/dev/null || true)
    if [ "$driver" = "i915" ] || [ "$driver" = "xe" ]; then
      echo "$node"
      return 0
    fi
  done
  return 1
}

# 480p 인코더. 원본(v:0)은 재인코딩 없이 그대로 복사한다.
if [ "${STREAM_ENCODER:-vaapi}" = "vaapi" ]; then
  DEVICE="${STREAM_VAAPI_DEVICE:-$(find_intel_render_node || echo /dev/dri/renderD128)}"
  echo "on-available: VAAPI device $DEVICE" >&2
  HW="-hwaccel vaapi -hwaccel_device $DEVICE -hwaccel_output_format vaapi"
  V1="-filter:v:1 scale_vaapi=w=-2:h=480 -c:v:1 h264_vaapi"
else
  HW=""
  V1="-filter:v:1 scale=-2:480 -c:v:1 libx264 -preset veryfast"
fi

ffmpeg에서 사용할 GPU 노드를 세팅하기 전, 미리 Intel GPU 렌더 노드가 무엇인지 찾는 로직을 선행하도록 수정했다. /sys/class/drm/renderD*/device/driver가 i915인 노드를 자동으로 찾도록 하여, 이러한 에러를 방지할 수 있었다.

/media/ 인가 관련 문제

HLS, VOD 파일들이 저장되어있는 /media/ 폴더에 대한 접근 권한 검사를 nginx과 연결하는 과정에서 두 가지 오류가 발생했다.

모든 /media/ 요청이 403(Forbidden)

문제

# nginx.conf
	location = /_media_auth {
        internal;
        proxy_pass http://spring_backend/api/internal/media/authorize;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $uri; # <- 문제 지점
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
	
    location /media/ {
        auth_request /_media_auth;
        alias /srv/media/;
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t                    ts;
            video/mp4                     mp4;
        }
        add_header Cache-Control $media_cache_control;
    }

호스트 PC의 /srv/media/과 연결한 location /media/에 접근하려 할 경우 403 에러가 발생했다.

원인

/media/ 접근에 필요한 auth_request인 /_media_auth에서, 원래 요청의 경로를 의미하는 X-Original-URI헤더에 $uri값을 할당했었다.
그러나 이는 원래 요청의 경로가 아니라 요청 내에서 정규화 된 현재 URI, 즉 /_media_auth 자신이었다. 참고 - ngx_http_core_module
이로 인해 Spring에서는 X-Original-URI: /_media_auth을 받으니, URI가 /media/hls/... 또는 /media/vod/...여야 한다는 형식 검사에서 걸려 항상 403 에러를 반환했다.

해결

    location /media/ {
		set $media_uri $uri;   # `rewrite` 단계(`access` 단계인 `auth_request`보다 먼저 진행됨). 정규화된 경로를 하위 요청에 넘기기 위해 변수로 저장한다.
        auth_request /_media_auth;
        alias /srv/media/;
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t                    ts;
            video/mp4                     mp4;
        }
        add_header Cache-Control $media_cache_control;
    }

auth_request 요청을 보내기 전에 set을 이용해 변수로 현재 URI를 저장해, auth_request와 공유하도록 수정했다.
이는 nginx가 HTTP 요청을 단계별로 처리한다는 점을 이용한 것이다.
공식 문서에 따르면, nginx의 HTTP 요청은 11개의 Phase를 거친다. 이 중, rewrite 명령문 및 set과 같은 변수 연산은 NGX_HTTP_REWRITE_PHASE에서 이뤄지고, auth_request와 같이 요청에 대한 사용자의 인가 여부를 조회하는 NGX_HTTP_ACCESS_PHASE는 그 이후에 진행된다.
따라서 /media/의 하위 요청인 auth_request(/_media_auth)가 만들어지기 전, set이 먼저 수행되며, 또한 하위 요청과 부모 요청은 변수 공간을 공유하기 때문에 부모 요청에서 set으로 기록한 변수를 하위 요청에서 접근할 수 있다.

… A subrequest looks like a normal request, but shares some data with its parent. In particular, all fields related to client input are shared because a subrequest does not receive any other input from the client.
(하위 요청은 일반 요청과 비슷해 보이지만, 상위 요청과 일부 데이터를 공유합니다. 특히, 하위 요청은 클라이언트로부터 다른 입력을 받지 않기 때문에 클라이언트 입력과 관련된 모든 필드가 공유됩니다.)
출처

이를 활용해, 부모 요청에서 현재 uri를 $media_uri라는 변수에 저장하고, 하위 요청에서 이를 사용(proxy_set_header X-Original-URI $media_uri)하게 하였다.

전체 원본 URI 값인 $request_uri도 있기 하지만, 이는 디코딩 전의 원문 값이기 때문에 그대로 가져다 쓰면 공격당할 위험이 있어 쓰지 않았다.
(ex. 공격자가 A 공연만 결제한 상태로 /media/hls/A/1/%2e%2e/%2e%2e/B/1/master.m3u8처럼 요청한다고 할 경우, Spring 서버에서는 /media/hls/... 포맷을 통과하므로 넘어가지만, nginx에서 해당 경로 파일을 찾기위해 %2e(URL 인코딩 된 .)을 디코딩 할 경우 B 공연의 파일을 내주게 된다.)

모든 /media/ 요청이 500(Internal Server Error)

문제

이번엔 /media/로 접근시, auth_request과정에서 500 에러 가 발생했다.

원인

# nginx.conf
upstream spring_backend {
    server spring:8080;
	# ...
}
server {
    location = /_media_auth {
        internal;
        proxy_pass http://spring_backend/api/internal/media/authorize;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        # proxy_set_header Host {값} < 이렇게 명시적으로 지정하지 않으면
		# nginx는 upstream 이름을 담아 보낸다.
        proxy_set_header X-Original-URI $media_uri;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
	# ...
}

서버 측에서 /media/내의 파일을 내주기 전 수행하는 auth_request 하위 요청(/_media_auth)의 Host가 지정되어있지 않아, nginx는 $proxy_host 값, 즉 upstream 이름(spring_backend)으로 보냈다.

공식문서에 따르면 프록시된 요청에 대해, 사용자가 proxy_set_header Host를 직접 명시하지 않을 경우, Host로 $proxy_host 값을 설정한다.
이는 proxy_pass 문에 명시된 프록시 서버의 이름과 포트이며(참고), 따라서 http://spring_backend/api/internal/media/authorize 중 서버 이름인 spring_backend가 Host로 설정된다.

_ 가 든 Host를 담은 요청을 받게된 Tomcat은 밑줄을 허용하지 않는 호스트명 규칙(RFC 952/1035/1123)으로 인해 400으로 요청을 거부했고, auth_request에서는 2xx/401/403이외의 응답은 500으로 바꾸어 반환하기 때문에 사용자측에서 500 에러를 받게 되었다.

RFC 952 - 미국 국방부 인터넷 호스트 테이블 사양, 1985: 호스트 이름을 알파벳(A-Z), 숫자(0-9), 마이너스 기호(-), 마침표(.)로 구성된 최대 24자 길이의 문자열로 가정함. (첫 번째 문자는 반드시 알파벳)
RFC 1035 - 도메인 이름 구현 및 사양, 1987: 도메인을 구성하는 라벨은 ARPANET 호스트 이름 규칙(RFC 952)을 따라야 함. 즉, 라벨은 문자로 시작해야 하며, 문자로 끝날 수도 있고 숫자로 끝날 수도 있어야 하며, 중간에는 문자,숫자 및 하이픈만 포함되어야 함.
RFC 1123 - 인터넷 호스트에 대한 요구 사항, 1989: RFC-952에서 첫 번째 문자가 알파벳이어야 한다는 제한을 완화하여, 알파벳 또는 숫자중 하나를 사용할 수 있도록 함.

Tomcat 8.5.31부터, 모든 커넥터에 대해 host name과 port에 대한 엄격한 유효성 검사를 활성화 했으며, 유효하지 않은 host name 및 port가 포함된 요청은 400응답과 함께 거부하도록 함. 실제 사례 - The character [_] is never valid in a domain name.

Tomcat 8.5.31 (markt) > Coyote
Enable strict validation of the provided host name and port for all connectors. Requests with invalid host names and/or ports will be rejected with a 400 response. (모든 커넥터에 대해 지정된 호스트 이름과 포트에 대한 엄격한 유효성 검사를 활성화합니다. 유효하지 않은 호스트 이름 및/또는 포트가 포함된 요청은 400 응답과 함께 거부됩니다.)
출처: https://tomcat.apache.org/tomcat-8.5-doc/changelog.html

해결

# nginx.conf
server {
    location = /_media_auth {
        internal;
        proxy_pass http://spring_backend/api/internal/media/authorize;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header Host $host; # 추가
        proxy_set_header X-Original-URI $media_uri;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
	# ...
}

이를 방지하기 위해, auth_request 하위 요청(/_media_auth)의 proxy header Host에 값을 세팅해주도록 설정하였다.

cloudsoswift