Project

Lambda + CDN을 통한 스트리밍 영상 제공

lsh2613 2025. 2. 19. 02:10

1. 개요

현재 진행하고 있는 프로젝트에서 주 도메인이 영상과 이미지이다.

따라서 효율적인 콘텐츠 제공을 위해 영상은 트랜스코딩을 통하여 스트리밍 파일로 저장하고, 이와 함께 이미지도 CDN을 통해 배포하였고 이 과정을 정리해보려 한다.

 

HLS(HTTP Live Streaming)

내가 적용한 스트리밍은 HLS로 적응형 스트리밍(Adaptive HTTP Streaming) 또는 적응형 비트레이트 스트리밍(ABR, Adaptive Bitrate Streaming)의 일종이다.

이를 통해 유저의 네트워크 환경에 따라 최적화된 다양한 영상 품질(해상도)를 제공할 수 있다.

 

후보군으로 MPEG-DASH를 생각했지만, 다음과 같은 이유로 HLS를 선택하였다

  1. 구현 난이도
  2. 자료

DASH는 국제 표준이고 HLS는 Apple에서 개발됐다. 따라서 HLS는 Apple사의 ios, safari 네이티브하게 지원되지만, DASH는 더 많은 부분에서 지원한다고 나와있다.

그럼에도 HLS를 선택한 이유는 백엔드 측면에서는 크게 상관없었고, 프론트 쪽에서  네이티브(아마 <video> 태그를 말하는 걸로 이해했음) 방식이 아니더라도 video.js를 통해 쉽게 처리할 수 있다고 했기 때문이다.

 

 

2. 설계

설계 방법은 AWS Video on demand를 참고하였다.

https://aws.amazon.com/ko/solutions/implementations/video-on-demand-on-aws/

 

위 사진을 보고 다음과 같이 정리하였다

  • origin video를 저장하는 S3(source)
  • S3(source)에 영상이 저장되면 트리거를 발생시키는 Lambda
  • video를 트랜스코딩 하여 스트리밍 파일로 변환하는 Elemental MediaConverter
  • 스트리밍 파일을 저장하는 S3
  • CloudFront를 통한 배포로

 

하지만, 나의 프로젝트에서 영상은 다소 짧은 영상으로 MedialConverter까지는 적용하지 않고, Lambda에서 FFmpeg Static Builds 라이브러리를 통해 처리하였다.

 

3. 구현

S3

가장 먼저 S3(source, destination, bin)을 만들어야 한다

bin은 FFmpeg라이브러리 파일을 보관하기 위한 용도이다

 

모두 ACL 비활성화, 모든 퍼블릭 액세스 차단하여 만들어줬다. 간단하므로 이후 설명은 생략

 

FFmpeg Static Buillds를 람다에서 활용하기 위해 위 사이트에서 다음 파일을 설치 후 압축을 풀어준다

 

압축 해제된 폴더를 ffmpeg 이름으로 변경 후 다시 압축시켜 ffmpeg.zip 파일을 만든다

 

해당 파일을 bin s3에 업로드 후 객체 URL을 복사한다

 

Lambda

Labmda -> 계층 -> 계층 생성

 

위에서 복사한 ffmpeg 객체 URL을 통해 계층을 업로드한다

람다를 Java로 만들 예정이기 때문에 호환 런타임을 Java21로 설정해뒀다

 

Lambda를 생성한 후 해당 Lambda에 계층(Layer)를 등록만 해주면 된다.

ffmpeg 라이브러리는 /opt/ffmpeg/ffmpeg 경로를 통해 사용할 수 있다

 

이제 람다 코드를 작성해보자

 

Lambda에서 Java 코드 편집기를 제공하지 않고 빌드파일(zip, jar)를 업로드 해야한다

 

build.gradle

dependencies {
    testImplementation platform('org.junit:junit-bom:5.9.1')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    // AWS S3
    implementation platform('software.amazon.awssdk:bom:2.28.16')
    implementation 'software.amazon.awssdk:s3'
    // AWS Lambda
    implementation 'com.amazonaws:aws-lambda-java-core:1.2.2'
    implementation 'com.amazonaws:aws-lambda-java-events:3.11.1'
    runtimeOnly 'com.amazonaws:aws-lambda-java-log4j2:1.5.1'
}

task buildZip(type: Zip) {
    into('lib') {
        from(jar)
        from(configurations.runtimeClasspath)
    }
}
단순히 build하면 의존성 라이브러리들이 포함되지 않는다.
따라서 런타임에 필요한 의존성들을 같이 포함시켜서 zip을 만들어주는 buildZip task를 실행해주자
결과는 build/distributuions/*.zip 으로 생성된다

 

SegmentCreator

public class SegmentsCreator implements RequestHandler<S3Event, String> {
    private final String DESTINATION_BUCKET_NAME = "{destination-s3-name}";
    private final String DESTINATION_BUCKET_DIR = "{s3-dir}"; // 생략 가능
    private final String LAMBDA_STORAGE_PATH = "/tmp";
    private final String FFMPEG_PATH = "/opt/ffmpeg/ffmpeg";
    private final String M3U8_EXTENSION = "m3u8";
    private final String TS_EXTENSION = "ts";

    @Override
    public String handleRequest(S3Event s3event, Context context) {
        LambdaLogger logger = context.getLogger();

        S3Client s3Client = S3Client.builder()
                .region(Region.AP_NORTHEAST_2)
                .build();

        try {
            // S3에 새로 생성된 원본 동영상 파일 가져오기
            S3EventNotification.S3EventNotificationRecord record = s3event.getRecords().get(0);
            String srcBucket = record.getS3().getBucket().getName();
            String srcKey = record.getS3().getObject().getUrlDecodedKey();
            InputStream object = getObject(s3Client, srcBucket, srcKey);
            String videoName = getVideoName(srcKey);
            String videoExtension = getFileExtension(srcKey);

            // Lambda /tmp에 해당 파일 저장
            String inputPath = createInputPath(videoName, videoExtension);
            saveVideoInTmp(object, inputPath);

            // Ffmpeg 명령문 생성 및 실행
            String tsOutputFormat = createTsOutputFormat(videoName);
            String m3u8OutputFormat = createM3u8OutputFormat(videoName);
            String[] ffmpegCmdArray = generateFfmpegCmdArray(tsOutputFormat, m3u8OutputFormat, inputPath);

            logger.log("Video encoding start");
            Process process = Runtime.getRuntime().exec(ffmpegCmdArray);
            process.waitFor();
            logger.log("Video encoding completed");

            // 세그먼트 파일들 S3에 저장
            saveSegmentsInDestinationBucket(logger, s3Client, videoName);

            return "ok";
        } catch (InterruptedException | IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void saveSegmentsInDestinationBucket(LambdaLogger logger, S3Client s3Client, String videoName) throws FileNotFoundException {
        File directory = new File(LAMBDA_STORAGE_PATH);
        File[] files = directory.listFiles();

        for (File file : files) {
            String originalName = file.getName();
            String fileName = getVideoName(originalName);
            String fileExtension = getFileExtension(originalName);

            if (validateFileExtension(fileExtension) && validateFileName(fileName, videoName)) {
                logger.log("Found file: " + originalName);
                long length = file.length();
                InputStream stream = new FileInputStream(file);
                putObject(s3Client, originalName, logger, stream, length);
            }
        }
    }

    private String[] generateFfmpegCmdArray(String tsOutputFormat, String m3u8OutputFormat, String inputPath) {
        String[] ffmpegCmdArray = {
                FFMPEG_PATH,
                "-i", inputPath,
                "-s", "1080x720",
                "-hls_time", "10",
                "-hls_list_size", "0",
                "-hls_segment_filename", tsOutputFormat,
                "-f", "hls",
                m3u8OutputFormat
        };
        return ffmpegCmdArray;
    }

    public void saveVideoInTmp(InputStream inputStream, String inputPath) throws IOException {
        File file = new File(inputPath);

        // 파일이 존재하지 않으면 생성
        if (!file.exists()) {
            file.createNewFile();
        }

        // InputStream을 /tmp에 파일로 저장
        try (OutputStream outputStream = new FileOutputStream(file)) {
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }
        } finally {
            inputStream.close();  // InputStream을 닫음
        }
    }

    private InputStream getObject(S3Client s3Client, String bucket, String key) {
        GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();

        return s3Client.getObject(getObjectRequest);
    }

    private void putObject(S3Client s3Client, String key, LambdaLogger logger, InputStream stream, long length) {
        String fullKeyName = DESTINATION_BUCKET_DIR + "/" + key;

        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(DESTINATION_BUCKET_NAME)
                .key(fullKeyName)
                .contentLength(length)
                .build();

        logger.log("Writing to: " + DESTINATION_BUCKET_NAME + "/" + fullKeyName);
        try {
            s3Client.putObject(putObjectRequest,
                    RequestBody.fromInputStream(stream, length));
        } catch (AwsServiceException e) {
            logger.log(e.awsErrorDetails().errorMessage());
            System.exit(1);
        }
    }

    private String createTsOutputFormat(String videoName) {
        return LAMBDA_STORAGE_PATH + "/" + videoName + "_%03d" + "." + TS_EXTENSION;
    }

    private String createM3u8OutputFormat(String videoName) {
        return LAMBDA_STORAGE_PATH + "/" + videoName + "." + M3U8_EXTENSION;
    }

    private String createInputPath(String videoName, String videoExtension) {
        return LAMBDA_STORAGE_PATH + "/" + videoName + "." + videoExtension;
    }

    private String getVideoName(String originalName) {
        return originalName.split("\\.")[0];
    }

    private String getFileExtension(String originalName) {
        return originalName.substring(originalName.lastIndexOf(".") + 1);
    }

    private boolean validateFileExtension(String fileExtension) {
        return fileExtension.equals(M3U8_EXTENSION) || fileExtension.equals(TS_EXTENSION);
    }

    private boolean validateFileName(String fileName, String videoName) {
        return fileName.startsWith(videoName);
    }

}

 

buildZip을 통해 생성된 zip 파일을 람다에 업로드해준다

 

이제 해당 람다 코드가 source S3에 업로드 시 실행되도록 트리거를 생성해줘야 한다

source와 destination을 분리하여 source S3에는 영상만 업로드 되기 때문에 접미사를 통해 확장자를 구분해주지 않고 모든 객체에 대해 트리거를 설정하였다

 

마지막으로 람다 제한 시간을 변경해줘야 한다.

람다 생성 시 기본으로 15초로 설정되어 있을 것이다. 트랜스코딩 과정은 무조건 15초 이상은 걸리기 때문에 넉넉하게 5분으로 변경해주었다

생성한 람다 함수 -> 구성 -> 일반 구성 ->  편집

 

여기까지 잘 적용됐다면 source S3에 영상 업로드 시 destination S3로 스트리밍 파일(ts, m3u8)이 저장될 것이다

CDN

AWS에서는 CDN을 CloudFront로 제공한다

CloudFront -> 배포 -> 생성

 

검은 박스에는 모두 destination-s3가 선택되어야 한다

이렇게 생성된 cdn이 s3에 접근하기 위해선 s3에 접근 정책을 수정해야 한다

 

 

생성된 배포 -> 원본 -> 편집

 

destination S3 -> 권한 -> 버킷 정책 -> 편집

 

정책 붙여넣기 -> 저장

 

여기까지가 구현이 끝이났다.

 

4. 결과

S3가 아닌 CDN을 통해 접근하도록 만들어뒀기 때문에 호출 경로가 약간 바꼈다

https://{s3-bucket-name}/{region}/{객체key} -> https://{cdn-배포-도메인-이름}/{객체key}

 

위에서 언급했듯이 hls는 크롬에서 네이티브하게 지원하지 않고 video.js를 사용해야 하기 때문에 당장 보여드리긴 어렵다(프론트 통신 테스트까지는 완료)

 

따라서 이미지로 대체하여 cdn 결과를 확인해보았다