Infra

hls를 위한 cloudfront + signed cookie + lambda@edge

하우아유두잉 2023. 11. 13. 17:39

이 글을 쓰는 목적?
hls를 처음 접하고 구현하는데 삽질을 많이 해서, 다른 이들은 그 과정을 거치지 않게 하고 함이다.

해당글을 참고해서 진행을 했는데,,, 빠진 부분이 있어서, 고생을 좀 했다.
초보자들도 그냥 설정을 따라서 그대로 세팅하면 되도록 작성하겠다.


 

영상 컨텐츠 서비스를 hls로 운영하기 위한 작업을 했다.

 

hls란?
Http Live Streaming

 

기존 서비스는 영상을 재생 하려면 .mp4 파일을 통으로 다운로드해서 재생을 했다면,

hls를 적용시키면 영상 파일을 1~2분 단위로 .ts 확장자로 쪼개어 저장하고, .m3u8에 메타데이터를 기록해서, 순차적으로 다운로드 받으면서 재생된다.

실시간 서비스에 주로 이용된다.

 

지금 당장 실시간 서비스를 하려는게 목적은 아니다.

보안 차원에서 진행하게 되었다.

 

자 그럼 구축을 한번 해보자.

 


 

1. 동영상 파일 쪼개기

해당 사이트에서 .mp4 파일을 .m3u8로 변환 시킬 수 있다.
https://www.mp4.to/m3u8/?lang=ko

 

MP4.to - MP4 변환기

MP4.to를 사용하면 MP4를 MP3로, MP4를 GIF로, MP4를 WAV로 변환하거나 온라인 MP4 플레이어 등을 사용할 수 있습니다. 무엇이든 MP4로 변환 할 수도 있습니다.

www.mp4.to

또는 ffmpeg을 써서 직접 변환도 가능하다.

 

변환을 완료하면 다음과 같은 파일목록을 갖게 된다.

 

.m3u8의 내용은 아래와 같다.

.ts 파일들의 목록을 가지고 있다.

 

 


 

2. S3 버킷 생성 및 레퍼지토리에 업로드

버킷 권한(permissions)은 아래와 같다.

 

나는 test라는 폴더를 만들고, 거기에 업로드 했다.

 

버킷 정책(policy)은 이후에 언급하겠다. 일단 비어두자.

 


 

3. Cloudfront 생성

이 글을 참고하자

https://bekusib.tistory.com/779

 

aws cloudfront를 이용해서 s3에 있는 나의 컨텐츠 보호하기(feat. signed url)

aws에서 동영상 서비스를 구축중에 있다. 그런 와중에 영상 유출 방지를 위한 대책이 필요했다. 기본적으로 public에서 url 접근을 허용해 서비스를 개발하였고, 이제 컨텐츠 보호를 위해 인프라 내

bekusib.tistory.com

 

다시 간략하게 설명을 하자면,

   1. key groups과 public keys를 만든다.

   2. Distributions을 만든다.

   3. origin / behaviors를 세팅한다.

결과물 >>
- Public key

 

 

- Key groups

 

 

- Distributions

 

- Distributions > Origin


- Origin access control

S3 bucket policy

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": [
                "s3:ListBucketMultipartUploads",
                "s3:GetBucketLocation",
                "s3:ListBucket"
            ],
            "Resource": "arn:aws:s3:::bucket-name/*"
        }
    ]
}

 

- Distributions > Behaviors

.ts 파일 접근용 기본 Default(*) behaviors와 .m3u8 파일을 접근할때 사용할 behaviors를 추가적으로 만들어 준다.

이유는, hls 플레이어 재생시 제일 먼저 .m3u8의 파일을 가져오는데 이때 lambda@edge를 이용해 Response Header에 sigend cookie를 반환시키기 때문이다. (구체적인 플로우 설명은 뒤에 하겠다.)

 

- Distributions > Behaviors > Default(*)

 

- Distributions > Behaviors > /*/*.m3u8

 

- Policies > Response headers > hls-cookie-cors

 

 


 

자 이제 기본적인 Cloudfront 세팅은 끝났다!

이제 cloudfront 도메인 주소를 통해 s3 origin에 접근 할 수 있다.


하지만, /*/*.m3u8은 public으로 접근 가능하지만(Behavior설정 참고),

Default(*)는 에세스 제한이 있다. signed url 또는 signed cookie를 통해 접근을 할 수 있다.

 

.m3u8을 public으로 접근 가능하게 한 이유는, HLS로 영상을 재생할 경우 .m3u8의.ts 파일 리스트를 얻어와야 되기 때문이다.

hls.js를 사용하면 플레이어(<video />)에서 자동으로 .ts 파일을 불러와 재생이 된다.

이떄 .ts 파일은 signed cookie를 이용해서 가져올 수 있다.

 

여기서, .ts파일이 복수개가 있는데, 그때마다 signed cookie를 만들어서 요청하는건 번거롭지 않을 수 없다.

그래서 폴더를 signed 처리해서 그 폴더 안의 모든 파일들은 같은 signed cookie로 접근 하게 할 수 있다.

(이 방법은 lambda@edge를 작성할떄 설명하겠다.)

 

 

자 그럼 이제 코드짜러 가보자.


 

4. hls.js를 사용한 React 웹페이지

hls.js를 사용해서 재생시키는 코드 예이다.

config 설정이 가장 중요한 포인트다.

withCredentilas를 true로 설정해줘야 쿠키 처리 하는게 먹힌다.

 

코드를 한마디로 요약하면 <video src="*.m3u8" />이다.

// main.tsx
import React, { useRef } from "react";
import { render } from "react-dom";
import "./index.css";
import Hls from "hls.js";


const config = {
  xhrSetup: function (xhr: any, url: any) {
    xhr.withCredentials = true; // do send cookies
    url = url + "?t=" + new Date().getTime();
    xhr.open("GET", url, true);
  },
};

function App() {
  const [hls, setHls] = React.useState(new Hls(config));
  const videoEl = React.useRef(null);

  React.useEffect(() => {
    const video = videoEl.current;

    const videoUrl =
      "https://d***********s.cloudfront.net/test/testvideo-2.m3u8";
    const timestampedUrl = `${videoUrl}?t=${new Date().getTime()}`;

    if (video) {
      if (Hls.isSupported()) {
        console.log("hls is supported!");
        hls.attachMedia(video);
        hls.on(Hls.Events.MEDIA_ATTACHED, () => {
          hls.loadSource(
            videoUrl
          );
        });
      } else {
        console.log("hls is not supported!");
      }
    }

    return () => {
      if (hls) {
        hls.destroy();
      }
      setHls(new Hls());
    };
  });

  return (
    <div className="App">
      <video ref={videoEl} controls>
      </video>
    </div>
  );
}

const rootElement = document.getElementById("root");
render(
  <CookiesProvider>
    <App />
  </CookiesProvider>,
  rootElement
);

 


 

5. Lambda@edge 추가

먼저 기본 Lambda 함수는 모든 리전에서 생성 및 사용 가능하다.

하지만 Lambda@dege는 us-east-1에서만 가능하다.

그래서 해당 리전에서 함수를 만들어야 한다.

 

나의 경우 node.js 18.x 기반으로 코드를 작성했다.

5-1. index.mjs에 복붙.

'use strict';
import { getSignedCookies, getSignedUrl } from "@aws-sdk/cloudfront-signer";
import * as fs from 'fs';

export const handler = async (event, context, callback) => {
  // Get Datas
    const response = event.Records[0].cf.response;
    const request = event.Records[0].cf.request;

    const keyPairId = "K***********";
    const privateKey = fs.readFileSync('./private-key.pem', {encoding:'utf8', flag:'r'});

     
    const policy = {
        Statement: [
            {
                Resource: 'https://d**********s.cloudfront.net/*',
                Condition: {
                  DateLessThan: {
                    'AWS:EpochTime':
                      Math.floor(new Date().getTime() / 1000) + 60 * 60 * 10,
                  },
                },
            },
        ],
    };
    
    const payload = {
      url: 'https://d**********s.cloudfront.net/*',
      keyPairId,
      privateKey,
      policy: JSON.stringify(policy)
    }
    const cookie = getSignedCookies(
      payload
    );
    response.headers['set-cookie'] = [
        {
            key: 'Set-Cookie',
            value: `CloudFront-Key-Pair-Id=${cookie['CloudFront-Key-Pair-Id']}; httpOnly; secure; SameSite=None`
        },
        {
            key: 'Set-Cookie',
            value: `CloudFront-Policy=${cookie['CloudFront-Policy']}; httpOnly; secure; SameSite=None`
        },
        {
            key: 'Set-Cookie',
            value: `CloudFront-Signature=${cookie['CloudFront-Signature']}; httpOnly; secure; SameSite=None`
        }
    ]
    callback(null, response);
};

 

5-2. private-key.pem 추가

 

5-3. test JSON

{
  "Records": [
    {
      "cf": {
        "config": {
          "distributionDomainName": "your-cloudfront-domain.cloudfront.net",
          "distributionId": "EXAMPLE12345"
        },
        "request": {
          "uri": "/path/to/your/test.m3u8",
          "method": "GET",
          "headers": {
            "host": [
              {
                "key": "Host",
                "value": "your-cloudfront-domain.cloudfront.net"
              }
            ]
          }
        },
        "response": {
          "status": "200",
          "statusDescription": "OK",
          "headers": {
            "content-type": [
              {
                "key": "Content-Type",
                "value": "application/vnd.apple.mpegurl"
              }
            ]
          },
          "body": "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:10.010000,\ntestvideo-20.ts\n#EXTINF:10.010000,\ntestvideo-21.ts\n#EXTINF:10.010000,\ntestvideo-22.ts\n#EXTINF:10.010000,\ntestvideo-23.ts\n#EXTINF:10.010000,\ntestvideo-24.ts\n#EXTINF:10.010000,\ntestvideo-25.ts\n#EXTINF:10.010000,\ntestvideo-26.ts\n#EXTINF:10.010000,\ntestvideo-27.ts\n#EXTINF:10.010000,\ntestvideo-28.ts\n#EXTINF:10.010000,\ntestvideo-29.ts\n#EXTINF:10.010000,\ntestvideo-210.ts\n#EXTINF:10.010000,\ntestvideo-211.ts\n#EXTINF:10.010000,\ntestvideo-212.ts\n#EXTINF:10.010000,\ntestvideo-213.ts\n#EXTINF:10.010000,\ntestvideo-214.ts\n#EXTINF:10.010000,\ntestvideo-215.ts\n#EXTINF:10.010000,\ntestvideo-216.ts\n#EXTINF:10.010000,\ntestvideo-217.ts\n#EXTINF:4.838167,\ntestvideo-218.ts\n#EXT-X-ENDLIST"
        }
      }
    }
  ]
}

 

테스트 결과

 

5-4. 다음으로 labmda@edge 사용에 앞서 permissions 세팅을 해준다.

 

아래와 같이 JSON을 덮어씌어주자.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "edgelambda.amazonaws.com",
                    "lambda.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

 

 

5-5. 이제 Cloudfront에 Trigger 추가를 하자.

 

 


 

이제 cloudfront를 통해 .m3u8 파일을 요청하게 되면, viewer response에서 작성한 lambda 함수가 실행된다.

 

 

여기서 CloudFront event에 대해 조금 더 알아보자.

아래 그림과 같이 총 4개의 이벤트로 이루어져 있다.

우리는 요청 후 반환되는 가장 마지막 단계인 viwer response에서 람다 함수를 실행시켜서 사용자(브라우저)가 Response Header의 Set-Cookie를 통해 sigend cookie를 받을 수 있게 한다.


 

6. 실행(테스트)

 

6-1. 크롬

 

문제 없다면 브라우저 쿠키에 자동으로 추가 된다.

 

 

6-2. 사파리

사파리의 경우 크롬(크롬계열)과 다르게 한가지 설정을 바꿔줘야한다.

기본적인 브라우저 정책이라, 직접 바꿔주는 방법 외에 해결 방법을 찾지 못했다.

 

 


 

이상으로 글을 마친다.