Entenda o que é serverless e veja como pode ser interessante para alguns cenários | exemplo com Azure Functions

Entenda a computação serverless e seu potencial de economia de recursos. Vamos explorar esse conceito e demonstrar com um exemplo prático: criaremos uma API em Node.js hospedada no Microsoft Azure Functions para adicionar uma marca d'água em uma imagem.

Banner de divulgação. Fundo cinza claro, com uma imagem parcial de um notebook com um celular em cima.
Um pouco sobre serverless: veja o que é serverless e quais cenários ele pode ser vantajoso

A primeira vez que ouvi o termo serverless, o que me veio na mente foi a tradução literal "sem servidor" e é claro que pensei errado 😅. Na verdade, serverless é um serviço em nuvem que permite executar seu projeto sem a necessidade de gerenciar servidores. Você fornece o código fonte e indica o runtime (Node.js, .NET, Java, etc.), e o provedor de nuvem se encarrega da infraestrutura e da escalabilidade. Essa é uma ótima abordagem para microsserviços, APIs e outras aplicações que exigem escalabilidade automática e baixo custo.

Este modelo de serviço simplifica bastante a vida do desenvolvedor, pois não precisamos nos preocupar com nada relacionado a infraestrutura. Fazendo uma analogia, seria como enviar um pacote para uma transportadora, você entrega o pacote e o endereço, então a empresa tem total autonomia para decidir como transportará o pacote até o destino, seja usando um caminhão, um avião ou qualquer outro meio de transporte.

Uma outra vantagem deste modelo é o custo. Suponha que você hospede uma pequena API no modelo serverless. Você só pagará enquanto sua aplicação estiver em uso. Caso sua aplicação não receba requisições em um determinado período, ou seja, fique ociosa, o provedor cloud desalocará os recursos e alocará para outras coisas. Desta forma você não está consumindo nenhum poder computacional, portanto, não irá pagar. Você só paga quando a aplicação está de fato em execução.

Devido as características já mencionadas, o modelo serverless se encaixam bem em cenários que sofrem demandas pontuais, ou seja, serviços que são executados eventualmente. Desta forma, enquanto o serviço não sofre nenhuma demanda, você não paga por isso. Podemos destacar alguns exemplos como:

  • Processar uma fila de mensagens
  • Processar dados como imagens ou arquivos
  • Executar uma tarefa agendada - por exemplo, executar uma limpeza de logs

Dito isto, neste texto vou mostrar como criar um pequeno serviço com uma responsabilidade bem específica. Também vou mostrar como hospedar o serviço no Azure Functions, que é uma das soluções serverless da Microsoft.

O que é o Azure Functions?

De forma bem direta, Azure Functions é a solução serverless da Microsoft onde podemos subir um código fonte, indicar o runtime e executar o código. Realmente é assim, bem simples.

O Azure Functions é uma solução sem servidor que permite que você escreva menos código, mantenha menos infraestrutura e economize nos custos. Em vez de se preocupar com a implantação e manutenção de servidores, a infraestrutura de nuvem fornece todos os recursos atualizados necessários para manter seus aplicativos em execução.
Você se concentra no código que mais importa para você, na linguagem mais produtiva para você, e o Azure Functions manipula o restante.
Para obter a melhor experiência com a documentação do Functions, escolha sua linguagem de desenvolvimento preferida na lista de linguagens nativas do Functions na parte superior do artigo.

https://learn.microsoft.com/pt-br/azure/azure-functions/functions-overview?pivots=programming-language-csharp

Em outros provedores cloud este mesmo serviço tem outros nomes, como o AWS Lambda e Google Cloud Functions.

Criando uma API para aplicar uma watermark nas imagens

O objetivo que será utilizado como exemplo neste texto é criar uma API com um único método. Este método irá receber uma imagem, irá aplicar uma marca d'água e então devolverá o resultado na resposta da requisição.

Como vou mostrar este exemplo na Azure, vou utilizar duas ferramentas específicas da Azure, porém, estas ferramentas não impactam no desenvolvimento da lógica da API:

Criando o projeto

Para criar o projeto já no modelo da Azure Functions, podemos utilizar logo de cara o comando func new. O func faz parte do Azure Functions Core Tools e facilita nossa vida na hora de criar, executar e publicar uma função.

Assim que executamos o func new, ele pede outras informações como runtime, language e template. Selecionei as opções node, typescript e HTTP Trigger.

💡
Ao publicar um código/serviço no Azure Functions ou em outro serviço serverless, o código permanece na nuvem, em um estado inativo. Então nós configuramos qual evento irá disparar a execução do código, por exemplo, uma requisição HTTP. Então sempre que uma requisição HTTP for efetuada o código será executado. Um outro exemplo, podemos programar o gatilho (trigger) para disparar a cada hora, então a cada hora o código será executado. Link falando sobre os gatilhos (triggers) no Azure Functions.
$ func new

Select a number for worker runtime:
1. dotnet
2. dotnet (isolated process)
3. node
4. python
5. powershell
6. custom
Choose option: 3
node

Select a number for language:
1. javascript
2. typescript
Choose option: 2
typescript

Select a number for template:
1. Azure Blob Storage trigger
2. Azure Cosmos DB trigger
3. Durable Functions activity
4. Durable Functions entity
5. Durable Functions Entity HTTP starter
6. Durable Functions HTTP starter
7. Durable Functions orchestrator
8. Azure Event Grid trigger
9. Azure Event Hub trigger
10. HTTP trigger
11. IoT Hub (Event Hub)
12. Kafka output
13. Kafka trigger
14. Azure Queue Storage trigger
15. RabbitMQ trigger
16. SendGrid
17. Azure Service Bus Queue trigger
18. Azure Service Bus Topic trigger
19. SignalR negotiate HTTP trigger
20. Timer trigger
Choose option: 10
HTTP trigger

Function name: [HttpTrigger] aplicarMarcaDAgua

Após executar o comando, o projeto será criado. Na sequência executei npm i para instalar as dependências. Por fim, executei o npm start para testar:

$ npm start

> [email protected] prestart
> npm run build

> [email protected] build
> tsc

> [email protected] start
> func start

Azure Functions Core Tools
Core Tools Version:       4.0.5611 Commit hash: N/A +591b8aec842e333a87ea9e23ba390bb5effe0655 (64-bit)
Function Runtime Version: 4.31.1.22191

Worker process started and initialized.

Functions:

        aplicarMarcaDAgua: [GET, POST] http://localhost:7071/api/aplicarMarcaDAgua

Com o código em execução, podemos efetuar uma requisição HTTP para testar. Lembrando que ao criarmos o projeto com o func, ele já insere um pequeno código que retorna uma mensagem como resposta da requisição. Utilizei o Curl para testar, mas também poderia usar qualquer outro cliente TCP, como um navegador ou o Postman.

$ curl -i http://localhost:7071/api/aplicarMarcaDAgua
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Thu, 28 Mar 2024 21:03:11 GMT
Server: Kestrel
Transfer-Encoding: chunked

This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.% 

Escrevendo a lógica para aplicar a marca d'água na imagem

Agora que o projeto está criado e funcionando, vamos focar no desenvolvimento da lógica.

Para aplicar a watermark, vou usar a lib sharp. Então para instalar a lib utilizei o seguinte comando:

npm i sharp

Com tudo isso pronto, é hora de codificar! Irei dividir o código fonte em dois tópicos. Farei isso para separar o que pode ser utilizado em qualquer provedor de nuvem, ou seja, a parte independente do provedor de nuvem, e a parte específica da nuvem, que neste caso é a Microsoft Azure Functions.

Parte do código agnóstico ao provedor de cloud

A classe que aplica a marca d'água é bem simples. Boa parte do código refere-se ao uso da lib sharp e a outra é uma pequena lógica para repetir a marca d'água na imagem.

Vale comentar que no construtor adicionei duas funções de log. A ideia é que ao instanciar essa classe, vamos passar como parâmetro duas funções para log que já estão integradas com a Azure. Assim conseguimos deixar esta classe agnóstica ao provedor de cloud.

import * as sharp from 'sharp';
import { OverlayOptions } from 'sharp';

export class WatermarkApplicator {
  objSourceImg: sharp.Sharp;

  constructor(
    sourceImg: Buffer,
    private logInfo: (...args: any) => void,
    private logError: (...args: any) => void
  ) {
    try {
      this.objSourceImg = sharp(sourceImg);
    } catch (error) {
      this.logError('Error creating Sharp object', error);
      throw error;
    }
  }

  async getDimensions(): Promise<{
    width: number;
    height: number;
  }> {
    try {
      const imgMetadata = await this.objSourceImg.metadata();
      return {
        width: imgMetadata.width ?? 100,
        height: imgMetadata.height ?? 100,
      };
    } catch (error) {
      this.logError('Error fetching metadata', error);
      throw error;
    }
  }

  async createWaterMark(
    text: string,
    textWidth: number
  ): Promise<Buffer> {
    return sharp({
      text: {
        // Suporte a Pango markup. Exemplo:
        // text: `<span foreground="#fff">${text}</span>`,
        text: `${text}`,
        rgba: true,
        align: 'center',
        justify: true,
        width: Math.round(textWidth * 0.2),
        dpi: 200,
      },
    })
      .rotate(45, {
        background: { r: 0, g: 0, b: 0, alpha: 0 },
      })
      .png({ palette: true })
      .toBuffer();
  }

  async apply(text: string) {
    try {
      this.logInfo(`Applying text: ${text}`);

      const { width, height } = await this.getDimensions();
      this.logInfo(`Dimensions: ${width} x ${height}`);

      const objWaterMark = await this.createWaterMark(
        text,
        width
      );

      this.logInfo('Sharp object with watermark created');

      const twentyPercent = 0.2;
      const marginX = width * twentyPercent;
      const marginY = height * twentyPercent;

      const layers: OverlayOptions[] = [];
      for (let x = 0; x < width; x += marginX) {
        for (let y = 0; y < height; y += marginY) {
          layers.push({
            input: objWaterMark,
            top: Math.round(y),
            left: Math.round(x),
          });
        }
      }

      this.logInfo(`Applying ${layers.length} watermarks`);

      return this.objSourceImg
        .composite(layers)
        .jpeg()
        .toBuffer();
    } catch (error) {
      this.logError('Error applying watermark', error);
      throw error;
    }
  }
}

watermark-applicator.ts

Parte do código da Microsoft Azure Functions

Agora vamos falar da parte do código exclusivo da Azure Functions.

Primeiramente limitei as requisições para aceitar somente o método POST. Fiz isto alterando o arquivo functions.json na propriedade methods:

{
  "bindings": [
    {
      "authLevel": "function",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": ["post"]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ],
  "scriptFile": "../dist/aplicarMarcaDAgua/index.js"
}

function.json

Na sequência criei um arquivo com funções responsáveis pela validação:

import { HttpRequest, FormPart } from '@azure/functions';

export function isMultiPartFormData(req: HttpRequest) {
  const contentType = req.headers['content-type'];
  return contentType.indexOf('multipart/form-data') >= 0;
}

export function validateAndFetchImageFieldErrors(
  formField: FormPart | null
): string {
  if (!formField) {
    return '"image" was not found in the body';
  }

  const supportedTypes = ['image/png', 'image/jpeg'];
  if (!supportedTypes.includes(formField.contentType || '')) {
    return `Unsupported image type. Supported types are: ${supportedTypes.join(
      ', '
    )}`;
  }

  const image = formField.value;
  if (image.length === 0) {
    return 'Image size must be greater than zero';
  }

  return '';
}

export function validateAndFetchTextFieldErrors(
  formField: FormPart | null
): string {
  if (!formField) {
    return '"watermarkText" was not found in the body';
  }

  const value = formField?.value.toString('utf8');

  if (
    typeof value !== 'string' ||
    !(value.length >= 1 && value.length <= 20)
  ) {
    return `"watermarkText" must be between 1 and 20 characters`;
  }

  return '';
}

validation-functions.ts

Por último, modifiquei o arquivo index.ts, onde o processamento da requisição tem início.

import {
  AzureFunction,
  Context,
  HttpRequest,
} from '@azure/functions';

import { HttpStatusCode } from './http-status-code';
import {
  isMultiPartFormData,
  validateAndFetchImageFieldErrors,
  validateAndFetchTextFieldErrors,
} from './validation-functions';
import { WatermarkApplicator } from './watermark-applicator';

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  if (!isMultiPartFormData(req)) {
    context.res = {
      status: HttpStatusCode.UnsupportedMediaType,
      body: 'Supports only multipart/form-data',
    };

    return;
  }

  try {
    // *****************************************************
    // * Validations
    // *****************************************************
    const form = req.parseFormBody();
    const formImage = form.get('image');
    const formWatermarkText = form.get('watermarkText');

    const validationErrors = [
      validateAndFetchImageFieldErrors(formImage),
      validateAndFetchTextFieldErrors(formWatermarkText),
    ].filter(Boolean);

    if (validationErrors.length) {
      context.log(validationErrors);
      context.res = {
        status: HttpStatusCode.BadRequest,
        body: validationErrors.join('\n'),
      };

      return;
    }

    const imgBuffer = formImage!.value;
    const text = formWatermarkText!.value.toString('utf-8');

    // *****************************************************
    // * Processing Start
    // *****************************************************
    const imageProcessor = new WatermarkApplicator(
      imgBuffer,
      (...args: any) => context.log(args),
      (...args: any) => context.log.error(args)
    );

    const imageWithWatermark = await imageProcessor.apply(text);

    context.res = {
      status: HttpStatusCode.Ok,
      body: imageWithWatermark,
      headers: {
        'Content-Type': 'image/jpg',
        'Content-Length': imageWithWatermark.length,
      },
    };
  } catch (error) {
    context.log.error('Error applying watermark', error);

    context.res = {
      status: HttpStatusCode.InternalServerError,
      body: 'An internal error occurred.',
    };
  }
};

export default httpTrigger;

index.ts

Agora podemos executar o código usando o npm start e para testar podemos usar novamente o CURL para postar uma imagem. Lembrando que o arquivo "imagem.jpg" deve estar no mesmo diretório em que você está executando o comando abaixo:

curl -X POST \
  -F "image=@./imagem.jpg" \
  -F "watermarkText=consolelog.com.br" \
  -o resultado.jpg \
  http://localhost:7071/api/aplicarMarcaDAgua

Abaixo há a imagem original e a imagem resultante do processamento:

Duas imagens dispostas verticalmente. A imagem de cima é a original, sem a aplicação da marca d'água. A imagem de baixo é praticamente igual a imagem de cima, porém com várias marcas d'água dispostas uniformemente sobre a imagem. Cada marca d'água contém o texto "consolelog.com.br"
Resultado

Agora que o código está pronto, vamos configurar os recursos necessários na Azure para publicar o código.

Criando os recursos necessários e publicando o código no Azure Functions

Para criar os recursos na Azure, podemos utilizar o portal da Azure ou o Azure CLI e o Azure Functions Core Tools para publicar o serviço. Vou utilizar o CLI e o Core Tools:

# Faz o login na Azure
$ az login

# Cria o Resource Group
$ az group create \
  --name "consolelog-function-watermark" \
  --location eastus2

# Cria o Storage Account para armazenar o
# código do serviço que será publicado
$ az storage account create \
  --resource-group "consolelog-function-watermark" \
  --location eastus2 \
  --name consolelogstoragefunc \
  --sku Standard_LRS

# Cria o Azure Functions
$ az functionapp create \
  --name "consolelog-image-functions" \
  --storage-account "consolelogstoragefunc" \
  --resource-group consolelog-function-watermark \
  --consumption-plan-location eastus2 \
  --os-type Linux \
  --runtime node \
  --runtime-version 20 \
  --functions-version 4

# Publica o serviço com o Functions Core Tools
$ func azure functionapp publish \
  "consolelog-image-functions" \
  --build remote

Um detalhe importante! Observe que no último comando acima, optei por usar a opção --build remote. Isso significa que a Azure será responsável por executar a compilação do projeto. Tomei essa decisão após perder 2 horas tentando entender por que o projeto funcionava em meu computador, mas não na Azure.

O motivo era simples: sempre que instalamos a biblioteca sharp com o comando npm i sharp, ocorre um processo de compilação automática para gerar os binários que a biblioteca utilizará. Esses binários são específicos para o sistema operacional onde a instalação está sendo realizada. Dito isso, em meu computador, a arquitetura é arm64 (processador M1), enquanto na Azure, para o plano que selecionamos, é Linux x64. Portanto, o que foi compilado pela biblioteca sharp em meu computador não funcionará em outra arquitetura.

Consequentemente, quando apenas compilei o projeto localmente e publiquei utilizando o comando func azure functionapp publish "consolelog-image-functions", tudo que foi gerado em meu computador foi enviado para a Azure, e é claro que não funcionou. A mensagem de erro alegava que não foi possível encontrar o módulo sharp:

Could not load the "sharp" module using the linux-x64 runtime.

Por essa razão, incluí a opção --build remote para que a Azure execute a compilação em um computador Linux x64, conforme nosso plano selecionado.

Testando a Azure Function

Como não habilitamos o acesso anônimo à nossa função, é necessário antes consultar qual é a chave de acesso para que possamos efetuar uma requisição. Para consultar a chave de acesso, podemos utilizar o portal da Azure ou o CLI conforme o comando abaixo:

az functionapp function keys list \
  --name "consolelog-image-functions" \
  --resource-group "consolelog-function-watermark" \
  --function-name "aplicarMarcaDAgua"

Com a chave em mãos, podemos testar montando a requisição através do CURL ou outro cliente TCP de sua preferência:

curl -X POST \
  -H "x-functions-key: CHAVE_OBTIDA_NO_CMD_ACIMA" \
  -F "image=@./imagem.jpg" \
  -F "watermarkText=consolelog-na-azure" \
  -o "resultado-azure.jpg" \
  https://consolelog-image-functions.azurewebsites.net/api/aplicarmarcadagua

Ao executar o comando acima, a imagem "resultado-azure.jpg" foi gerada:

Imagem utilizada no exemplo com várias marcas d'água dispostas uniformemente sobre a imagem. Cada marca d'água contém o texto "consolelog-na-azure"
Resultado

Considerações

Os serviços serverless podem proporcionar economia de recursos e simplificar a gestão, pois toda a infraestrutura é abstraída. O exemplo discutido neste texto, um serviço com um único endpoint para adicionar uma marca d'água em uma imagem, ilustra bem essa praticidade no mundo real. Como esse serviço não é constantemente utilizado, optar por uma abordagem serverless permite economizar recursos.

Outro ponto relevante é a capacidade de escalabilidade automática oferecida pelos provedores cloud. Se a demanda aumentar, o provedor de nuvem escalonará automaticamente mais instâncias para atender à demanda. No entanto, é importante observar que existem limites de escalabilidade e outros parâmetros, como o tempo de execução, que variam de acordo com o provedor de nuvem e o plano escolhido. Portanto, ao trabalhar com um provedor de nuvem específico, é recomendável consultar a documentação do serviço para garantir que o plano escolhido seja a melhor opção para o seu projeto.

Link do projeto no Github: https://github.com/marcelovismari/image-watermarking-with-sharp-typescript-azure-functions

Links interessantes: