Gerando Thumbnails com NODEJS, FFMPEG, AWS Lambda e Terraform
Tags: node, terraform, aws, ffmpeg
Published: 2020-04-20 13:52:32

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](https://pcmporto@bitbucket.org/pcmporto/lambda_thumbnail_generator.git).

###Quais ferramentas vamos utilizar?

*[AWS Lambda](https://aws.amazon.com/pt/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)](https://aws.amazon.com/pt/sqs/)*
Serviço de enfileiramento de mensagens.

*[AWS S3](https://aws.amazon.com/pt/s3/)*
Serviço de armazenamento com excelente disponibilidade, segurança e durabilidade.

*[FFMpeg](https://www.ffmpeg.org/)*
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](https://nodejs.org/en/)*
Motor(run time) multiplataforma construída para executar código Javascript.

*[Terraform](https://www.terraform.io/)*
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](https://en.wikipedia.org/wiki/Thumbnail) dos vídeos de nossos usuários, teve junto em sua implantação a ferramenta ffmpeg no mesmo [Container](https://kubernetes.io/docs/concepts/containers/). 

*Nossas aplicações estão em um ambiente [kubernetes](https://kubernetes.io/docs/concepts/overview/what-is-kubernetes/).*
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/yfd969waymztt07ofqxf.jpg)

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](https://kubernetes.io/docs/concepts/workloads/pods/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](https://dev-to-uploads.s3.amazonaws.com/i/ut3we0dmpq1m3zc0dl0s.jpg)
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**
```javascript
// 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()
```
Utilizando o aws-sdk criaremos uma classe para realizar o upload da imagem gerada para o s3.
**s3-util.js**
```javascript
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()
```
E, novamente com a ajuda do aws-sdk criaremos outra classe com a responsabilidade de enviar mensagens para uma fila SQS.
**sqs-util.js**
```javascript
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()
```
Criaremos mais duas classes: uma para receber e tratar a mensagem recebida pelo SQS e outra para processar a mensagem.

app.js
```javascript
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
```
index.js
```javascript
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;
```
####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.*
```terraform
resource "aws_s3_bucket" "application-uploader-files-bucket" {
  bucket = "example-application-uploader"
  acl    = "private"

  tags = {
    Team      = "Devops"
    Terraform = "TRUE"
  }
}
```
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.
```terraform
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"
  }
}
```
Vamos criar um segundo bucket para salvar as imagens geradas pelo lambda
```terraform
resource "aws_s3_bucket" "thumbnails-s3-bucket" {
  bucket = "example-thumbnail-generator-files"
  acl    = "private"

  tags = {
    Team       = "Thumbnail"
    Terraform = "TRUE"
  }
}
```

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

```terraform
# 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
}
```

###Implantação
Você pode clicar [aqui](https://youtu.be/HHmsifXN8Jw) para ver um vídeo com o passo a passo para implantação ou seguir o script a baixo.
```shellscript
#!/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
```

###Testes

Abra o console da AWS no navegador e acesse a página do Sqs
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/9kbtjgy5hb3p5vtlv4db.jpg)
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](https://bitbucket.org/pcmporto/lambda_thumbnail_generator/raw/343532b25a000e97328a071372c2d67711d0bcc1/images/cloud_watch_logs.jpg)

Sucesso! Vamos abrir a página do Sqs novamente e dar uma olhada na fila de resposta.
![Alt Text](https://bitbucket.org/pcmporto/lambda_thumbnail_generator/raw/343532b25a000e97328a071372c2d67711d0bcc1/images/sqs_thumbnail_success.jpg)

###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.