라이브 서버

나는 RTMP 입력을 받아들여 자체 적응 비트레이트(ABR) HLS를 출력하는 실시간 흐름 서버를 구축하기로 결정했다.
https://github.com/rgfindl/live-streaming-server
나는 사용자가 수시로 그들의 개인 흐름 키를 사용하여 흐름을 진행할 수 있기를 바란다.트위터, 페이스북, 유튜브처럼 하세요.
나는 또한 생방송 흐름을 녹화하고 싶다. 사용자들이 그들의 생방송 흐름을 다른 목적지, 예를 들어 Twitch, 페이스북, 유튜브로 전송할 수 있기를 바란다.
브라우저, 페이스북, 트위터, 유튜브에서 흐르는 캡처입니다.

최종 구조는 사실상 3개 서비스: 프록시->서버<-Origin
나는 게시물과 이 블로그 글 시리즈에서 대리와 출처를 소개할 것이다.
아키텍처 보기:

모든 3가지 서비스는 AWS Fargate에서 Docker 컨테이너로 실행됩니다.
RTMP는 에이전트rtmp.finbits.io로 전송됩니다.
HLS는 시발소에서 제공됩니다live.finbits.io.
Redis 캐시는 HL을 가져오는 서버를 소스 사이트에서 알 수 있도록 스트리밍 키를 서버에 매핑합니다.우리는 많은 서버를 가지고 수요를 만족시킬 수 있다.
S3는 레코드를 저장하는 데 사용됩니다.녹음은 단비 특률 HLS입니다.ABR의 최대 비트레이트입니다.거주하다
모든 세 가지 서비스는 수요를 충족시키기 위해 독립적으로 확장할 수 있다.서버의 확장성이 가장 큽니다.RTMP를 ABR HLS로 코드화하는 것은 CPU를 많이 사용합니다.

노드 미디어 서버


RTMP 서버의 경우 Node Media Server fork를 사용하기로 결정했습니다.
https://github.com/rgfindl/Node-Media-Server
노드 미디어 서버는 포트 1935에서 RTMP를 사용합니다.그런 다음 FFMPEG를 사용하여 RTMP를 HLS로 코드를 바꿉니다.FFMPEG는 소셜 미디어 목적지까지 중계하는 데도 사용된다.
노드 미디어 서버를 선택해야 하는 이유
그것은 적극적으로 보호하는 것입니다. 그것은github별이 많습니다. 저는 node를 좋아합니다.js.
나는 처음으로 nginx-rtmp-module를 시도했다. 왜냐하면nginx가 매우 훌륭하기 때문이다.하지만 나는 사교 릴레이를 내가 원하는 방식으로 운영할 수 없다.그 밖에 이 프로젝트는 이미 더 이상 유지되지 않을 뿐만 아니라, 상당히 낡았다.
나는 또한ossrs/srs을 연구했는데, 그것은nginxrtmp 모듈을 바탕으로 하는 것 같다.그것은 내가 c/c++ 개발자가 아니기 때문에 그렇게 유연하지 않은 것 같다.
왜 포크 노드 미디어 서버를 선택합니까?내가 뭘 바꿨지?
HLS가 작동하도록 노드 미디어 서버config에 추가 옵션을 추가했습니다.특히 config.trans.tasks 대상.
const config = {
  ...
  trans: {
    tasks: [
      raw: [...], # FFMPEG command
      ouPaths: [...], # HLS output paths
      cleanup: false, # Don't delete the ouPaths, we'll do it later
    ]
  }
}
나는 아래에서 모든 문제를 상세하게 토론할 것이다.

FFMPEG


FFMPEG는 rtmp 입력 코드를 3개의 HLS 출력으로 변환하는 데 사용됩니다.
  • 640 x 360
  • 842 x 480
  • 720 x 1280
  • ffmpeg -hide_banner -y -fflags nobuffer -i rtmp://127.0.0.1:1935/stream/test \
      -vf scale=w=640:h=360:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v libx264 -preset veryfast -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_list_size 6 -hls_flags delete_segments -max_muxing_queue_size 1024 -start_number 100 -b:v 800k -maxrate 856k -bufsize 1200k -b:a 96k -hls_segment_filename media/test/360p/%03d.ts media/test/360p.m3u8 \
      -vf scale=w=842:h=480:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v libx264 -preset veryfast -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_list_size 6 -hls_flags delete_segments -max_muxing_queue_size 1024 -start_number 100 -b:v 1400k -maxrate 1498k -bufsize 2100k -b:a 128k -hls_segment_filename media/test/480p/%03d.ts media/test/480p.m3u8 \
      -vf scale=w=1280:h=720:force_original_aspect_ratio=decrease -c:a aac -ar 48000 -c:v libx264 -preset veryfast -profile:v main -crf 20 -sc_threshold 0 -g 48 -keyint_min 48 -hls_time 4 -hls_list_size 6 -hls_flags delete_segments -max_muxing_queue_size 1024 -start_number 100 -b:v 2800k -maxrate 2996k -bufsize 4200k -b:a 128k -hls_segment_filename media/test/720p/%03d.ts media/test/720p.m3u8
    
    
    ABR 재생 목록 파일은요?
    첫 번째 HLS 재생 목록 파일이 생성되면 생성됩니다.보아하니 이렇다.
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
    360p/index.m3u8
    #EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=842x480
    480p/index.m3u8
    #EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
    720p/index.m3u8
    

    서버


    우리 서버는 다음과 같은 작업을 수행합니다.
  • RTMP 가져오기 및 HLS로 변환
  • ABR HLS 재생 목록 만들기
  • 최고 비트레이트 HLS를 S3
  • 로 복제
  • 쿼리 매개 변수
  • 를 기반으로 소셜 목적지에 중계되는 RTMP
  • 흐르는 키 검증에 사용되는 갈고리 공개
  • NGINX 리버스 에이전트를 통해 HLS에 캐시 헤드 및 코어 제공
  • 응용 프로그램.회사 명


    app.js 대부분의 일을 끝냈다.우리 이 문서의 전체 내용을 좀 봅시다.
    const NodeMediaServer = require('node-media-server');
    const _ = require('lodash');
    const { join } = require('path');
    const querystring = require('querystring');
    const fs = require('./lib/fs');
    const hls = require('./lib/hls');
    const abr = require('./lib/abr');
    const ecs = require('./lib/ecs');
    const cache = require('./lib/cache');
    const logger = require('./lib/logger');
    const utils = require('./lib/utils');
    
    const LOG_TYPE = 4;
    logger.setLogType(LOG_TYPE);
    
    // init RTMP server
    const init = async () => {
      try {
        // Fetch the container server address (IP:PORT)
        // The IP is from the EC2 server.  The PORT is from the container.
        const SERVER_ADDRESS = process.env.NODE_ENV === 'production' ? await ecs.getServer() : '';
    
        // Set the Node-Media-Server config.
        const config = {
          logType: LOG_TYPE,
          rtmp: {
            port: 1935,
            chunk_size: 60000,
            gop_cache: true,
            ping: 30,
            ping_timeout: 60
          },
          http: {
            port: 8080,
            mediaroot: process.env.MEDIA_ROOT || 'media',
            allow_origin: '*',
            api: true
          },
          auth: {
            api: false
          },
          relay: {
            ffmpeg: process.env.FFMPEG_PATH || '/usr/local/bin/ffmpeg',
            tasks: [
              {
                app: 'stream',
                mode: 'push',
                edge: 'rtmp://127.0.0.1/hls',
              },
            ],
          },
          trans: {
            ffmpeg: process.env.FFMPEG_PATH || '/usr/local/bin/ffmpeg',
            tasks: [
              {
                app: 'hls',
                hls: true,
                raw: [
                  '-vf',
                  'scale=w=640:h=360:force_original_aspect_ratio=decrease',
                  '-c:a',
                  'aac',
                  '-ar',
                  '48000',
                  '-c:v',
                  'libx264',
                  '-preset',
                  'veryfast',
                  '-profile:v',
                  'main',
                  '-crf',
                  '20',
                  '-sc_threshold',
                  '0',
                  '-g',
                  '48',
                  '-keyint_min',
                  '48',
                  '-hls_time',
                  '6',
                  '-hls_list_size',
                  '10',
                  '-hls_flags',
                  'delete_segments',
                  '-max_muxing_queue_size',
                  '1024',
                  '-start_number',
                  '${timeInMilliseconds}',
                  '-b:v',
                  '800k',
                  '-maxrate',
                  '856k',
                  '-bufsize',
                  '1200k',
                  '-b:a',
                  '96k',
                  '-hls_segment_filename',
                  '${mediaroot}/${streamName}/360p/%03d.ts',
                  '${mediaroot}/${streamName}/360p/index.m3u8',
                  '-vf',
                  'scale=w=842:h=480:force_original_aspect_ratio=decrease',
                  '-c:a',
                  'aac',
                  '-ar',
                  '48000',
                  '-c:v',
                  'libx264',
                  '-preset',
                  'veryfast',
                  '-profile:v',
                  'main',
                  '-crf',
                  '20',
                  '-sc_threshold',
                  '0',
                  '-g',
                  '48',
                  '-keyint_min',
                  '48',
                  '-hls_time',
                  '6',
                  '-hls_list_size',
                  '10',
                  '-hls_flags',
                  'delete_segments',
                  '-max_muxing_queue_size',
                  '1024',
                  '-start_number',
                  '${timeInMilliseconds}',
                  '-b:v',
                  '1400k',
                  '-maxrate',
                  '1498k',
                  '-bufsize',
                  '2100k',
                  '-b:a',
                  '128k',
                  '-hls_segment_filename',
                  '${mediaroot}/${streamName}/480p/%03d.ts',
                  '${mediaroot}/${streamName}/480p/index.m3u8',
                  '-vf',
                  'scale=w=1280:h=720:force_original_aspect_ratio=decrease',
                  '-c:a',
                  'aac',
                  '-ar',
                  '48000',
                  '-c:v',
                  'libx264',
                  '-preset',
                  'veryfast',
                  '-profile:v',
                  'main',
                  '-crf',
                  '20',
                  '-sc_threshold',
                  '0',
                  '-g',
                  '48',
                  '-keyint_min',
                  '48',
                  '-hls_time',
                  '6',
                  '-hls_list_size',
                  '10',
                  '-hls_flags',
                  'delete_segments',
                  '-max_muxing_queue_size',
                  '1024',
                  '-start_number',
                  '${timeInMilliseconds}',
                  '-b:v',
                  '2800k',
                  '-maxrate',
                  '2996k',
                  '-bufsize',
                  '4200k',
                  '-b:a',
                  '128k',
                  '-hls_segment_filename',
                  '${mediaroot}/${streamName}/720p/%03d.ts',
                  '${mediaroot}/${streamName}/720p/index.m3u8'
                ],
                ouPaths: [
                  '${mediaroot}/${streamName}/360p',
                  '${mediaroot}/${streamName}/480p',
                  '${mediaroot}/${streamName}/720p'
                ],
                hlsFlags: '',
                cleanup: false,
              },
            ]
          },
        };
    
        // Construct the NodeMediaServer
        const nms = new NodeMediaServer(config);
    
        // Create the maps we'll need to track the current streams.
        this.dynamicSessions = new Map();
        this.streams = new Map();
    
        // Start the VOD S3 file watcher and sync.
        hls.recordHls(config, this.streams);
    
        //
        // HLS callbacks
        //
        hls.on('newHlsStream', async (name) => {
          // Create the ABR HLS playlist file.
          await abr.createPlaylist(config.http.mediaroot, name);
          // Send the "stream key" <-> "IP:PORT" mapping to Redis
          // This tells the Origin which Server has the HLS files
          await cache.set(name, SERVER_ADDRESS);
        });
    
        //
        // RTMP callbacks
        //
        nms.on('preConnect', (id, args) => {
          logger.log('[NodeEvent on preConnect]', `id=${id} args=${JSON.stringify(args)}`);
          // Pre connect authorization
          // let session = nms.getSession(id);
          // session.reject();
        });
    
        nms.on('postConnect', (id, args) => {
          logger.log('[NodeEvent on postConnect]', `id=${id} args=${JSON.stringify(args)}`);
        });
    
        nms.on('doneConnect', (id, args) => {
          logger.log('[NodeEvent on doneConnect]', `id=${id} args=${JSON.stringify(args)}`);
        });
    
        nms.on('prePublish', (id, StreamPath, args) => {
          logger.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
          // Pre publish authorization
          // let session = nms.getSession(id);
          // session.reject();
        });
    
        nms.on('postPublish', async (id, StreamPath, args) => {
          logger.log('[NodeEvent on postPublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
          if (StreamPath.indexOf('/hls/') != -1) {
            // Set the "stream key" <-> "id" mapping for this RTMP/HLS session
            // We use this when creating the DVR HLS playlist name on S3.
            const name = StreamPath.split('/').pop();
            this.streams.set(name, id);
          } else if (StreamPath.indexOf('/stream/') != -1) {
            //
            // Start Relay to youtube, facebook, and/or twitch
            //
            if (args.youtube) {
              const params = utils.getParams(args, 'youtube_');
              const query = _.isEmpty(params) ? '' : `?${querystring.stringify(params)}`;
              const url = `rtmp://a.rtmp.youtube.com/live2/${args.youtube}${query}`;
              const session = nms.nodeRelaySession({
                ffmpeg: config.relay.ffmpeg,
                inPath: `rtmp://127.0.0.1:${config.rtmp.port}${StreamPath}`,
                ouPath: url
              });
              session.id = `youtube-${id}`;
              session.on('end', (id) => {
                this.dynamicSessions.delete(id);
              });
              this.dynamicSessions.set(session.id, session);
              session.run();
            }
            if (args.facebook) {
              const params = utils.getParams(args, 'facebook_');
              const query = _.isEmpty(params) ? '' : `?${querystring.stringify(params)}`;
              const url = `rtmps://live-api-s.facebook.com:443/rtmp/${args.facebook}${query}`;
              session = nms.nodeRelaySession({
                ffmpeg: config.relay.ffmpeg,
                inPath: `rtmp://127.0.0.1:${config.rtmp.port}${StreamPath}`,
                ouPath: url
              });
              session.id = `facebook-${id}`;
              session.on('end', (id) => {
                this.dynamicSessions.delete(id);
              });
              this.dynamicSessions.set(session.id, session);
              session.run();
            }
            if (args.twitch) {
              const params = utils.getParams(args, 'twitch_');
              const query = _.isEmpty(params) ? '' : `?${querystring.stringify(params)}`;
              const url = `rtmp://live-jfk.twitch.tv/app/${args.twitch}${query}`;
              session = nms.nodeRelaySession({
                ffmpeg: config.relay.ffmpeg,
                inPath: `rtmp://127.0.0.1:${config.rtmp.port}${StreamPath}`,
                ouPath: url,
                raw: [
                  '-c:v',
                  'libx264',
                  '-preset',
                  'veryfast',
                  '-c:a',
                  'copy',
                  '-b:v',
                  '3500k',
                  '-maxrate',
                  '3750k',
                  '-bufsize',
                  '4200k',
                  '-s',
                  '1280x720',
                  '-r',
                  '30',
                  '-f',
                  'flv',
                  '-max_muxing_queue_size',
                  '1024',
                ]
              });
              session.id = `twitch-${id}`;
              session.on('end', (id) => {
                this.dynamicSessions.delete(id);
              });
              this.dynamicSessions.set(session.id, session);
              session.run();
            }
          }
        });
    
        nms.on('donePublish', async (id, StreamPath, args) => {
          logger.log('[NodeEvent on donePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
          if (StreamPath.indexOf('/hls/') != -1) {
            const name = StreamPath.split('/').pop();
            // Delete the Redis cache key for this stream
            await cache.del(name);
            // Wait a few minutes before deleting the HLS files on this Server
            // for this session
            const timeoutMs = _.isEqual(process.env.NODE_ENV, 'development') ?
              1000 : 
              2 * 60 * 1000;
            await utils.timeout(timeoutMs);
            if (!_.isEqual(await cache.get(name), SERVER_ADDRESS)) {
              // Only clean up if the stream isn't running.  
              // The user could have terminated then started again.
              try {
                // Cleanup directory
                logger.log('[Delete HLS Directory]', `dir=${join(config.http.mediaroot, name)}`);
                this.streams.delete(name);
                fs.rmdirSync(join(config.http.mediaroot, name));
              } catch (err) {
                logger.error(err);
              }
            }
          } else if (StreamPath.indexOf('/stream/') != -1) {
            //
            // Stop the Relay's
            //
            if (args.youtube) {
              let session = this.dynamicSessions.get(`youtube-${id}`);
              if (session) {
                session.end();
                this.dynamicSessions.delete(`youtube-${id}`);
              }
            }
            if (args.facebook) {
              let session = this.dynamicSessions.get(`facebook-${id}`);
              if (session) {
                session.end();
                this.dynamicSessions.delete(`facebook-${id}`);
              }
            }
            if (args.twitch) {
              let session = this.dynamicSessions.get(`twitch-${id}`);
              if (session) {
                session.end();
                this.dynamicSessions.delete(`twitch-${id}`);
              }
            }
          }
        });
    
        nms.on('prePlay', (id, StreamPath, args) => {
          logger.log('[NodeEvent on prePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
          // Pre play authorization
          // let session = nms.getSession(id);
          // session.reject();
        });
    
        nms.on('postPlay', (id, StreamPath, args) => {
          logger.log('[NodeEvent on postPlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
        });
    
        nms.on('donePlay', (id, StreamPath, args) => {
          logger.log('[NodeEvent on donePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`);
        });
    
        // Run the NodeMediaServer
        nms.run();
      } catch (err) {
        logger.log('Can\'t start app', err);
        process.exit();
      }
    };
    init();
    

    Url 구조


    응용 프로그램을 호출할 때, 당신의 URL은 이렇게 보입니다.
    소셜 검색 매개 변수는 선택할 수 있습니다.그것들이 나타날 때, 그것들은 상응하는 사회 목적지로 전달될 것이다.
    rtmp://rtmp.finbits.io:1935/stream/testkeyd?twitch=<your twitch key>&youtube=<your youtube key>&facebook=<your facebook key>&facebook_s_bl=<your facebook bl>&facebook_s_sc=<your facebook s_sc>&facebook_s_sw=<your facebook sw>&facebook_s_vt=<your facebook vt>&facebook_a=<your facebook a>
    

    디지털 녹화기


    최고 비트레이트 HLS가 S3로 복제됩니다.
    태클 경로는 다음과 같습니다.<bucket>/<stream key>/vod-<stream id>.m3u8
    #EXTM3U
    #EXT-X-VERSION:3
    #EXT-X-TARGETDURATION:6
    #EXT-X-MEDIA-SEQUENCE:1590665387025
    #EXTINF:6.000000,
    720p/1590665387025.ts
    #EXTINF:6.000000,
    720p/1590665387026.ts
    #EXT-X-ENDLIST
    

    흐름 키 검증

    preConnect 또는 prePublish RTMP 이벤트에 대해 스트림 키 인증을 수행할 수 있습니다.다음은 예입니다.
    nms.on('preConnect', (id, args) => {
      logger.log('[NodeEvent on preConnect]', `id=${id} args=${JSON.stringify(args)}`);
      // Pre connect authorization
      if (isInvalid) {
        let session = nms.getSession(id);
        session.reject();
      }
    });
    

    NGINX 회사


    NGINX를 리버스 에이전트로 사용하여 정적 HLS 파일을 서비스합니다.그것의 성능은 express보다 우수하다.js.
    worker_processes  auto;
    
    error_log /dev/stdout info;
    
    
    events {
      worker_connections  1024;
    }
    
    http {
      include       /etc/nginx/mime.types;
      default_type  application/octet-stream;
    
      log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                        '$status $body_bytes_sent "$http_referer" '
                        '"$http_user_agent" "$http_x_forwarded_for"';
    
      access_log /dev/stdout main;
    
      sendfile        on;
    
      keepalive_timeout  65;
    
      gzip on;
    
      ignore_invalid_headers off;
    
      upstream node-backend {
        server localhost:8080 max_fails=0;
      }
    
      server {
        listen 8000;
        server_name localhost;
        sendfile off;
    
        location ~ live\.m3u8 {
          add_header Cache-Control "max-age=60";
          root /usr/src/app/media;
        }
    
        location ~ index\.m3u8 {
          add_header Cache-Control "no-cache";
          root /usr/src/app/media;
        }
    
        location ~ \.ts {
          add_header Cache-Control "max-age=600";
          root /usr/src/app/media;
        }
    
        location /nginx_status {
          stub_status on;
    
          access_log off;
          allow 127.0.0.1;
          deny all;
        }
    
        location / {
          add_header Cache-Control "no-cache";
          proxy_pass http://node-backend/;
        }
      }
    }
    
    보시다시피 HLS 재생 목록 파일은 캐시되지 않습니다.우리는 확실히 ABR 파일과 *를 캐시했다.ts 미디어 파일.

    인프라


    전체 애플리케이션이 AWS에서 실행됩니다.프록시, 서버, 원본 Fargate 서비스를 시작하기 전에, 우리는 공유된 인프라 시설을 만들어야 한다.다음은 공유 인프라 목록입니다.

  • assets-S3 태클

  • vpc - Fargate 서비스 전용 네트워크

  • ecs - Fargate 서비스의 ECS 클러스터

  • security - Fargate 서비스의 보안 그룹

  • redis - 스트리밍 키를 IP 포트에 매핑하는 데 사용되는 Redis 캐시

  • proxy dns-rtmp.핀비트.io DNS
  • stack-up.sh 스크립트를 사용하여 각 스택을 AWS 계정에 배치할 수 있습니다.자격 증명 파일과 일치하려면 PROFILE="--profile bluefin" 을 변경해야 합니다.
    다음은 예입니다.
    sh ./stack-up.sh vpc
    

    서비스


    이제 우리는 공유할 인프라가 생겼다.서버를 배치합시다.
    서버 서비스에는 다음과 같은 두 개의 스택이 있습니다.

  • ecr-Docker 이미지 레지스트리

  • service - Fargate 서비스
  • 먼저 docker ECR 레지스트리를 작성합니다.
    sh ./stack-up.sh ecr
    
    이제 Docker 이미지를 구축하고 태그하여 레지스트리로 전송할 수 있습니다.
    AWS 계정 id를 포함하려면 먼저 package.json 스크립트를 업데이트하십시오.
    Docker 이미지를 구축, 태그하고 레지스트리로 전송하려면 다음 명령을 실행합니다.
    yarn run deploy <version>
    
    현재 우리는 서비스 창고를 배치할 수 있습니다. 새 이미지를Fargate에 배치할 것입니다.
    먼저 업데이트Versionhere.
    다음 작업을 실행합니다.
    sh ./stack-up.sh service
    
    서버가 ECS 클러스터에서 Fargate 작업으로 실행되어야 합니다.
    하지만직접 액세스할 수 없습니다.(
    RTMP를 게시하려면 RTMP 트래픽을 서버 그룹에 라우팅해야 합니다.
    HTTP 트래픽을 서버 그룹으로 라우팅해야 합니다.
    세 부분으로 구성된 이 시리즈의 다음 블로그를 보세요.
  • 섹션 1 - 서버
  • 제2부분 -
  • 제3부분 -
  • 원문 발표: https://finbits.io/blog/live-streaming-server/

    좋은 웹페이지 즐겨찾기