DEV Community

loading...

Gerando Thumbnails com NODEJS, FFMPEG, AWS Lambda e Terraform

Paulo Porto
senior software developer
Updated on ・9 min read

Este artigo é um exemplo/tutorial de como criar uma função lambda na AWS para gerar thumbnails.

Para entender melhor o conteúdo deste artigo, é necessário o conhecimento básico sobre terraform, o que é AWS (Amazon Web Services) e Node JS.

Download do código fonte aqui.

Quais ferramentas vamos utilizar?

AWS Lambda
Serviço para executar funções sem precisar da alocação de servidores. Possui diversos mecanismos de disparo, integra com as demais ferramentas da AWS e seu custo é baseado no tempo de execução e na quantidade de memória RAM alocada.

Informação importante, o lambda tem limitações de uso de disco (512MB na pasta /tmp).

AWS Sqs(Simple Queue Service)
Serviço de enfileiramento de mensagens.

AWS S3
Serviço de armazenamento com excelente disponibilidade, segurança e durabilidade.

FFMpeg
Ferramenta open-source composta por diversas bibliotecas para conversão, compactação, edição e até mesmo stream de vídeos e áudios.

Node JS
Motor(run time) multiplataforma construída para executar código Javascript.

Terraform
Ferramenta para criação de infraestrutura em Cloud Computing com código (AWS neste exemplo/tutorial).

Qual foi a minha motivação?

Durante alguns anos, nossa aplicação responsável por gerar Thumbnails dos vídeos de nossos usuários, teve junto em sua implantação a ferramenta ffmpeg no mesmo Container.

Nossas aplicações estão em um ambiente kubernetes.
Alt Text

Nossa plataforma tem um crescimento constante e nos últimos meses a aplicação de thumbnail apresentou erros durante a execução do ffmpeg. A ferramenta apresentava o erro associado ao consumo excessivo do processador e da memória do Pod.

Durante os maiores picos de requisição o provisionamento automático da aplicação não era suficiente e nem rápido o bastante para atender a demanda. Aumentar a memória dos Pods já não era mais viável.

Para resolver o problema em definitivo foi necessária uma pequena mudança na arquitetura da aplicação.
Alt Text
Criamos uma função lambda para realizar a tarefa de gerar thumbnails, adaptando nossas aplicações para trabalhar de forma assíncrona. A comunicação entre a API e a função lambda foi feita via filas de mensagem: uma fila para enviar as solicitações e outra para notificar a conclusão do trabalho.

Mãos à obra!

Node JS

Em nosso projeto temos três dependências cruciais:
ffmpeg-installer/ffmpeg
Realiza o download e faz a instalação do ffmpeg compatível
fluent-ffmpeg
ffmpeg é uma ferramenta executada em linha de comando. Esta dependência facilita a construção do comando em forma de objeto.
aws-sdk
Faz a integração com as ferramentas da AWS. Será utilizado para enviar mensagens para filas Sqs e realizar o upload para o s3 da imagem gerada.

Para começar, vamos criar uma classe para gerenciar a execução do ffmpeg.
thumbnail-util.js

// Busca onde o ffpmeg foi instalado
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path
var FFmpeg = require('fluent-ffmpeg')
FFmpeg.setFfmpegPath(ffmpegPath)

class ThumbnailGenerator {
    contentType () {
        return 'image/jpg'
    }

    exec (options) {
        new FFmpeg({ source: options.source })
            // Ignorar as trilhas de audio
            .withNoAudio()
            // Tempo do frame a ser utilizado
            .setStartTime(options.startTime)
            // Quantidade de frames a ser retirada
            .takeFrames(1)
            // Codec a ser utilizado
            .withVideoCodec('mjpeg')
            // Local para salvar o arquivo
            .saveToFile(options.output)
            // Imprimime o comando a ser executado
            .on('start', (commandLine) => {
                console.log(`command-line: ${commandLine}`)
            })
            // Se durante a execução do ffmpeg algum erro for lançado
            // o capturamos aqui
            .on('error', (err) => {
                console.log('Error generating thumbnail:')
                console.log(err)

                if (options.onError) {
                    options.onError(err)
                }
            })
            // Executado quando o comando terminar
            .on('end', () => {
                if (options.onEnd) {
                    options.onEnd()
                }
            })
    }
}

module.exports = new ThumbnailGenerator()
Enter fullscreen mode Exit fullscreen mode

Utilizando o aws-sdk criaremos uma classe para realizar o upload da imagem gerada para o s3.
s3-util.js

const AWS = require('aws-sdk')
const fs = require('fs')

//Não precisamos de nenhuma configuração adicional no client
//As credenciais já estão associadas a instância no lambda
let s3 = new AWS.S3()

//Criamos uma classe com a responsabilidade de subir nosso arquivo no bucket
class S3Util {
    upload(key, orign, contentType) {
        return s3.upload({
            Bucket: process.env.BUCKET,
            // caminho/caminho/arquivo.jpeg
            Key: key,
            Body: fs.createReadStream(orign),
            ACL: 'private',
            ContentType: contentType,
            StorageClass: 'STANDARD_IA'
        }).promise()
    }
}

module.exports = new S3Util()
Enter fullscreen mode Exit fullscreen mode

E, novamente com a ajuda do aws-sdk criaremos outra classe com a responsabilidade de enviar mensagens para uma fila SQS.
sqs-util.js

const AWS = require('aws-sdk')

class SqsUtil {
    constructor() {
        this.sqs = new AWS.SQS({region: process.env.REGION})
    }

    sendMessage (body, delay) {
        var sqsMessage = {
            // Caso você precise de atrasar a entrega da mensagem
            DelaySeconds: delay ? delay : 10,
            // As mensagens na fila precisam ser string
            MessageBody: JSON.stringify(body),
            QueueUrl: process.env.RESULT_QUEUE_URL
        };

        return new Promise( (res, rej) => {
            this.sqs.sendMessage(sqsMessage, (err, data) => {
                if (err) {
                    rej(err)
                } else {
                    res(data.MessageId)
                }
            })
        })
    }
}

module.exports = new SqsUtil()
Enter fullscreen mode Exit fullscreen mode

Criaremos mais duas classes: uma para receber e tratar a mensagem recebida pelo SQS e outra para processar a mensagem.

app.js

const thumbnail = require('./utils/thumbnail-util')
const s3util = require('./utils/s3-util')
const sqsUtil = require('./utils/sqs-util')

class App {
    constructor (source, path, startTime) {
        this.fileName = 'thumbnail.jpeg'
        this.output = `/tmp/${this.fileName}`
        this.bucketFileKey = `${path}/${this.fileName}`
        this.path = path
        this.source = source
        this.startTime = startTime
    }

    async run() {
        try {
            await this.generateThumbnail()
            await this.uploadThumbnail()
            await this.notifyDone()
        } catch (e) {
            console.log('Unexpected error')
            console.log(e)
            await this.notifyError()
        }
    }

    generateThumbnail () {
        console.log("generating thumbnail STARTED")
        return new Promise ( (res, rej) => {
            thumbnail.exec({
                source: this.source,
                output: this.output,
                startTime: this.startTime,
                onError: (err) => {
                    console.log(`generating thumbnail FINISHED WITH ERROR: ${err}`)
                    rej(err)
                },
                onEnd: () => {                    
                    console.log(`generating thumbnail FINISHED`)
                    res()
                }
            })
        })
    }

    uploadThumbnail () {
        console.log('Uploading thumbnail to S3')
        return s3util.upload(
            this.bucketFileKey,
            this.output,
            thumbnail.contentType())
    }


    notifyError() {
        let body = {
            source : this.source,
            startTime : this.startTime,
            key : this.bucketFileKey,
            path : this.path,
            success: false
        }
        console.log('Sending error message to Sqs')
        return sqsUtil.sendMessage(body, 0)
    }

    notifyDone() {
        let body = {
            source : this.source,
            startTime : this.startTime,
            key : this.bucketFileKey,
            path : this.path,
            success: true
        }
        console.log('Sending success message to Sqs')
        return sqsUtil.sendMessage(body, 0)
    }
}

module.exports = App
Enter fullscreen mode Exit fullscreen mode

index.js

const App = require('./main/app')

/* Função para validar o corpo da mensagem.
    {
        Records: [
            {
                body: "{raw json message}"
            }
        ]
    }
 */
let messageParser = (event) => {
    //Records[] sempre há um item no array
    let strbody = event.Records[0].body
    try {
        let message = JSON.parse(strbody)

        if (!message.hasOwnProperty('source') ||
            !message.hasOwnProperty('path') ||
            !message.hasOwnProperty('startTime')) {
                console.log('unparseable sqs message')
                console.log(message)
        } else {
            return message;
        }
    } catch (error) {
        console.log('unparseable sqs message')
        console.log(strbody)
    }   

}

//este é o método a ser executado inicialmente pelo lambda
exports.handler = (event, context) => {

    let message = messageParser(event)

    if (message) {
        let app = new App(
            //source será a url do vídeo
            message.source,
            //Path é o diretório no qual o arquivo gerado será salvo.
            message.path,
            //Segundo do vídeo do qual a imagem será extraída
            message.startTime)

        app.run()
    }

}

//Expondo o método método messageParser apenas para teste unitário
exports.messageParser = messageParser;
Enter fullscreen mode Exit fullscreen mode

Terraform

Inicialmente vamos utilizar o terraform para criar um bucket para fazer upload do código do lambda.
Criaremos um bucket privado com o nome “example-application-uploader” no s3 com a classe de armazenamento padrão (STANDARD). Ser privado significa que o acesso aos arquivos armazenados pode ser feito apenas por pessoas/aplicações autenticadas ou por URLs assinadas.

Obs: O código fonte do projeto contém dois diretórios para o terraform, pois este recurso pertence à infraestrutura e não à aplicação.

resource "aws_s3_bucket" "application-uploader-files-bucket" {
  bucket = "example-application-uploader"
  acl    = "private"

  tags = {
    Team      = "Devops"
    Terraform = "TRUE"
  }
}
Enter fullscreen mode Exit fullscreen mode

O código abaixo cria duas filas: uma para enviar ao lambda os vídeos que precisam da geração de thumbnails e outra com o resultado da operação. As filas possuem 5 minutos de retenção da mensagem, significando que a aplicação que consumir a mensagem tem até 5 minutos para processar e deletar a mensagem, caso contrário, esta voltará para a fila.

resource "aws_sqs_queue" "thumbnail_request_queue" {
  name = "thumbnail-request-queue"
  visibility_timeout_seconds = 300
  tags = {
    Team = "Thumbnail",
    Terraform = "TRUE"
  }
}

resource "aws_sqs_queue" "thumbnail_result_queue" {
  name = "thumbnail-result-queue"
  visibility_timeout_seconds = 300
  tags = {
    Team = "Thumbnail",
    Terraform = "TRUE"
  }
}
Enter fullscreen mode Exit fullscreen mode

Vamos criar um segundo bucket para salvar as imagens geradas pelo lambda

resource "aws_s3_bucket" "thumbnails-s3-bucket" {
  bucket = "example-thumbnail-generator-files"
  acl    = "private"

  tags = {
    Team       = "Thumbnail"
    Terraform = "TRUE"
  }
}
Enter fullscreen mode Exit fullscreen mode

O código a seguir, cria o lambda, o gatilho, as políticas de acesso e o Cloud Watch para armazenar o log.

# Cria grupo de log no cloudwatch.
# Infelizmente é a melhor forma de debugar o lambda (Cloud Watch custa caro)
# e tbm é o logger mais fácil de ser plugado no serviço.
resource "aws_cloudwatch_log_group" "thumbnail_generator_lambda_log_group" {
  name              = aws_lambda_function.example-thumbnail-generator-lambda.function_name
  retention_in_days = 1
}

#Criamos aqui a role com as permissões básicas para execução do serviço
resource "aws_iam_role" "thumbnail_generator_lambda_iam_role" {
  name = "thumbnail_generator_lambda_iam_role"

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

#aqui criamos uma política definindo quais são os recursos da aws que o lambda 
#pode acessar.
#Estamos o autorizando a escrever, enviar e apagar mensagens nas filas,
#ler, listar, salvar e editar arquivos no bucket e escrever os
#logs no Cloud Watch.
resource "aws_iam_role_policy" "thumbnail_generator_lambda_iam_policy" {
  name = "thumbnail_generator_lambda_iam_policy"
  role = aws_iam_role.thumbnail_generator_lambda_iam_role.id

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [ 
        "sqs:SendMessage",
        "sqs:ReceiveMessage",
        "sqs:DeleteMessage",
        "sqs:GetQueueAttributes",
        "sqs:ChangeMessageVisibility"
      ],
      "Resource": [
        "arn:aws:sqs:us-east-1:YOURACCOUNTID:thumbnail-request-queue",
        "arn:aws:sqs:us-east-1:YOURACCOUNTID:thumbnail-request-queue/*",
        "arn:aws:sqs:us-east-1:YOURACCOUNTID:thumbnail-result-queue",
        "arn:aws:sqs:us-east-1:YOURACCOUNTID:thumbnail-result-queue/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "sqs:ListQueues"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogStream",
        "logs:CreateLogGroup",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketLocation",
        "s3:PutObject",
        "s3:PutObjectAcl",
        "s3:GetObject",
        "s3:GetObjectAcl"
      ],
      "Resource": [
        "arn:aws:s3:::example-thumbnail-generator-files",
        "arn:aws:s3:::example-thumbnail-generator-files/*"
      ]
    }
  ]
}
EOF
}

#Cria a função lambda
resource "aws_lambda_function" "example-thumbnail-generator-lambda" {
  #Como nosso arquivo compactado é muito grande, uma conexão 
  #com baixa taxa de upload pode causar erro durante a execução do terraform.
  #Eu escolhi fazer o upload da aplicação para o s3 para evitar este tipo de problema
  s3_bucket        = "example-application-uploader"
  s3_key           = "thumbnail/lambda.zip"

  #Uma alternativa ao S3 é utilizar o filebase64sha256
  #recomendo apenas projetos onde o arquivo zip é pequeno.
  #filename         = "lambda.zip"
  #source_code_hash = filebase64sha256("lambda.zip")

  function_name    = "example_thumbnail_generator_lambda"
  role             = aws_iam_role.thumbnail_generator_lambda_iam_role.arn
  #Definição da localização do método principal
  handler          = "index.handler"
  runtime          = "nodejs10.x" // 12.x já disponível

  #Recomendo a utilização de 512MB de RAM para execução do lambda.
  #Fiz meus testes com um vídeo de 14.4Gb e o lambda gastou 438Mb de
  #memória. A quantidade de memória utilizada vai variar conforme o tamanho (em tempo e/ou arquivo).
  # que você pretende utilizar
  #memory_size      = 512

  memory_size      = 128 // Free Tier
  timeout          = 60 // Duração máxima obs: (no meu teste durou 5 segs com o arquivo de 14.4Gb)
  publish          = true

  #aqui podemos declarar as variáveis de ambiente. Muito útil para rodar a aplicação
  #em ambientes diferentes.
  environment {
    variables = {
      RESULT_QUEUE_URL  = "https://sqs.us-east-1.amazonaws.com/YOURACCOUNTID/thumbnail-result-queue",
      BUCKET            = "example-thumbnail-generator-files",
      REGION            = "us-east-1"
    }
  }
}

#Este trecho cria o gatilho do nosso lambda. No caso é a nossa fila thumbnail-request-queue.
#Basicamente sempre que chegar uma mensagem a aws dispara nosso lambda
resource "aws_lambda_event_source_mapping" "thumbnail_generator_lambda_source_mapping" {
  event_source_arn = "arn:aws:sqs:us-east-1:YOURACCOUNTID:thumbnail-request-queue"
  enabled          = true
  function_name    = aws_lambda_function.example-thumbnail-generator-lambda.arn
  #Maior número de registros que o lambda pode receber por execução
  batch_size       = 1
}
Enter fullscreen mode Exit fullscreen mode

Implantação

Você pode clicar aqui para ver um vídeo com o passo a passo para implantação ou seguir o script a baixo.

#!/bin/sh

cd terraform-infra

terraform init
terraform apply -auto-approve

cd ..

npm install --production
zip lambda.zip -r node_modules main package.json index.js

aws s3 cp lambda.zip s3://example-application-uploader/thumbnail/lambda.zip

cd terraform

terraform init
terraform apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

Testes

Abra o console da AWS no navegador e acesse a página do Sqs
Alt Text
Vamos enviar manualmente uma mensagem na fila thumbnail-request-queue para executar o lambda.
{ "source" : "https://somePublicVideo.mp4", "path" : "path/in/s3/we/want/save", "startTime" : 1 }

Vamos até o cloudwatch ver o log do lambda
Alt text

Sucesso! Vamos abrir a página do Sqs novamente e dar uma olhada na fila de resposta.
Alt Text

Conclusão

Nossos problemas com a geração dos thumbnails foram solucionados, uma vez que os erros com ffmpeg foram cessados. Ainda, reduzimos a quantidade de Pods, a quantidade de memória RAM e processador alocados para API de Thumbnail. Portanto, minha conclusão é que o Lambda é uma excelente forma de executar tarefas assíncronas, pois possuí fácil integração e pode aliviar o peso de processamento de dados complexos das APIs.

Já planejamos outras tarefas para migrar para o lambda, como a análise dos vídeos ou gerar watermark em documentos.

Essa foi a minha contribuição de hoje! Deixem nos comentários dúvidas ou compartilhe outras tarefas onde vocês também têm sucesso utilizando o lambda.

Espero ter ajudado, obrigado.

Discussion (0)