Skip to content

Cloud Functions로 음악 인코딩하기(성공편)

SeoKyung Lim edited this page Dec 14, 2023 · 1 revision

다시 도전!

우리의 현재 로직은 업로드 될 때마다 음악이 인코딩 되면 쓰이지 않는 파일(ts, m3u8)이 많이 발생될까 염려되어 음악 정보를 DB에 업로드 할 때 인코딩 하는 방식을 선택했다.

그러나 이렇게 되면 인코딩 하는데 시간이 3초~20초 안쪽까지도 넘어가는데 사용자는 이 과정이 완료될 때까지 제출 버튼만 누르고 기다려야 한다는 점이다!

그러던 찰나에, 채리님께서 custom image 활용하여 Cloud Functions에서 인코딩에 성공하셨다는 큰 한마디를 전해듣고..! 잘 모르던 docker도 봐볼겸 다시 도전해보기로 했다! 이렇게 되면 업로드할 때 클라우드에서 따로 인코딩이 돌아가기 때문에, 서버의 부담도 줄고 사용자가 기다릴 필요도 없음!)

일단 동작 순서

  1. 버킷의 특정 경로(/music 하위)에 음악(.mp3)이 업로드 되면 trigger 시킨다.
  2. ffmpeg, aws-sdk 등 인코딩에 필요한 모듈이 설치된 docker image에서 인코딩을 실시한다.
    1. params로 파일 정보가 넘어옴.
    2. 받아온 파일(mp3)을 특정 경로에 담아 저장
    3. ffmpeg을 통해 이 경로안의 파일을 인코딩 → 특정 경로에 저장 → 바로 Object Storage에 업로드!
  3. 변환 끝-!

docker 이미지 올리기

커스텀 컨테이너 이미지

Action/Trigger 실행

(ncloud 공식 문서를 보면 어떻게 파일을 작성해야 하는지 친절히 알려준다!)

사실 docker를 설치하지 않아서 docker를 설치하는 과정이 제일 오래 걸렸다. wsl의 버전을 최신으로 업데이트 해야 docker가 실행이 되어..

Dockerfile

FROM cloudfunctions.kr.ncr.ntruss.com/cloudfunctions-nodejs-16:latest

RUN npm install fluent-ffmpeg
RUN npm install @ffmpeg-installer/ffmpeg
RUN npm install axios
RUN npm install aws-sdk

WORKDIR /nodejsAction

CMD node --expose-gc app.js

powershell 명령어

# 아래의 로직을 따라 container에 push까지 해주면 이 이미지를 cloud functions에서 가져와주면 됨!

docker build . -t [container registry 이름].kr.ncr.ntruss.com/[image:tag]

docker login -u {NCP access key} catchy-tape-encode-registry.kr.ncr.ntruss.com
password: {NCP secret key}

docker push [container registry 이름].kr.ncr.ntruss.com/[image:tag]

인코딩 로직(은 기존의 인코딩 코드와 동일)

  • 코드가 보고 싶다면?

    const AWS = require('aws-sdk');
    const ffmpeg = require('fluent-ffmpeg');
    const { path } = require('@ffmpeg-installer/ffmpeg');
    const fs = require('fs');
    const axios = require('axios');
    const Path = require('path');
    
    function setObjectStorage(accessKey, secretKey) {
      return new AWS.S3({
        endpoint: 'https://kr.object.ncloudstorage.com',
        region: 'kr-standard',
        credentials: {
          accessKeyId: accessKey,
          secretAccessKey: secretKey,
        },
        signatureVersion: 'v4',
      });
    }
    
    function separateMusicName(musicPath) {
      const parsedPath = new URL(musicPath);
      const pathNames = parsedPath.pathname.split('/');
      const musicName = pathNames[pathNames.length - 1];
    
      return musicName;
    }
    
    function getPath(option) {
      return Path.resolve(`musics${option}`);
    }
    
    function setEncodingPaths(musicPath) {
      const musicName = separateMusicName(musicPath);
    
      return {
        outputMusicPath: getPath('/output'),
        entireMusicPath: getPath(''),
        outputPath: getPath(`/output/${musicName.replace('.mp3', '')}.m3u8`),
        tempFilePath: getPath(`/${musicName}`),
      };
    }
    
    async function uploadToObjectStorage(
      filePath,
      musicId,
      fileName,
      objectStorage,
    ) {
      const result = await objectStorage
        .upload({
          Bucket: 'catchy-tape-bucket2',
          Key: `music/${musicId}/${fileName}`,
          Body: fs.createReadStream(filePath),
          ACL: 'public-read',
        })
        .promise();
    
      return { url: result.Location };
    }
    
    async function uploadEncodedFile(filePath, musicId, fileName, objectStorage) {
      try {
        const { url } = await uploadToObjectStorage(
          filePath,
          musicId,
          fileName,
          objectStorage,
        );
        return url;
      } catch (err) {
        console.log(err);
      }
    }
    
    async function saveMp3File(outputMusicPath, musicPath, tempFilePath) {
      fs.mkdirSync(outputMusicPath, { recursive: true });
    
      const musicFileResponse = await axios.get(musicPath, {
        responseType: 'arraybuffer',
      });
    
      const musicBuffer = Buffer.from(musicFileResponse.data);
    
      fs.writeFile(tempFilePath, musicBuffer, (err) => {
        if (err) throw new Error();
      });
    }
    
    async function encodeMusic(musicId, musicPath, objectStorage) {
      try {
        ffmpeg.setFfmpegPath(path);
    
        const { outputMusicPath, outputPath, tempFilePath } =
          setEncodingPaths(musicPath);
    
        await saveMp3File(outputMusicPath, musicPath, tempFilePath);
    
        const encodedFileURL = await executeEncoding(
          tempFilePath,
          outputPath,
          outputMusicPath,
          musicId,
          objectStorage,
        );
    
        return encodedFileURL;
      } catch (err) {
        console.log(err);
      }
    }
    
    async function executeEncoding(
      tempFilePath,
      outputPath,
      outputMusicPath,
      musicId,
      objectStorage,
    ) {
      let m3u8FileName = '';
      let m3u8Path = '';
      const watcher = fs.watch(outputMusicPath, async (eventType, fileName) => {
        if (fileName.match(/.m3u8$/)) {
          m3u8FileName = fileName;
        } else if (!fileName.match(/\.tmp$/)) {
          await uploadEncodedFile(
            outputMusicPath + `/${fileName}`,
            musicId,
            fileName,
            objectStorage,
          );
        }
      });
    
      return await new Promise((resolve, reject) => {
        ffmpeg(tempFilePath)
          .addOption([
            '-map 0:a',
            '-c:a aac',
            '-b:a 192k',
            '-hls_time 30',
            '-hls_list_size 0',
            '-f hls',
          ])
          .output(outputPath)
          .on('end', async () => {
            watcher.close();
            m3u8Path = await uploadEncodedFile(
              outputMusicPath + `/${m3u8FileName}`,
              musicId,
              m3u8FileName,
              objectStorage,
            );
            resolve(m3u8Path);
          })
          .on('error', () => {
            reject(new Error());
          })
          .run();
      });
    }
    
    async function main(params) {
      const bucket = params.container_name;
      const music_id = params.music_id;
    
      const musicPath = `https://kr.object.ncloudstorage.com/${bucket}/music/${music_id}/music.mp3`;
      const objectStorage = setObjectStorage(params.accessKey, params.secretKey);
    
      const url = await encodeMusic(music_id, musicPath, objectStorage);
    
      return {
        statusCode: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          url,
        },
      };
    }

NCP 설정

(multipart/form-data 형태로 파일이 업로드 되어 이벤트 유형을 아래와 같이 설정했다)

Untitled

결과

  • 테스트: 3분 56초 음악 4.8s (기존에 인코딩을 개선했을 때 5초대까지가 최선이었음)

Untitled

다만!

이렇게 mp3 파일이 버킷에 업로드될 때 트리거가 발생하여 알아서 저장되면, 서버 입장에서는 언제 인코딩이 끝나는지 알 수 없었다. 그래서 웹 액션으로 방식을 바꿔주었다!

  1. API Gateway를 통해 웹 액션을 호출할 수 있는 외부 연결 주소 생성하기

Untitled

Untitled

(위의 그림처럼 직접 요청을 보냈을 때 인코딩 성공 후 응답이 오는 것을 확인!)

  1. 웹 액션에도 동일하게 custom imageapp.js 파일을 활용해 소스 코드 설정하기

  2. 음악 버킷에 업로드 요청 → 버킷에 업로드 → 1번의 URL에 POST 요청 → 인코딩 완료! → 응답

결론적으로는 위의 과정을 통해 서버로 응답이 돌아온다!

Action/Trigger 실행

위 페이지의 ‘외부 연결 주소 생성’ 목차를 통해 URL의 의미를 파악했고,

My Products

위 페이지의 ‘IAM 인증이 필요한 API 호출’ 목차를 통해 필요한 인증 헤더를 넣어주었다.

Untitled

결론

성공 ~! ~!

Untitled

그러나 서버에서 인코딩 에러가 가끔씩 발생한다. 이건 (아직까지 생각하기에는) 간헐적으로 발생하는 에러라고 추측됨. (일단 NCP 탓)

Clone this wiki locally