Arquivo MN-004-01 · Nota de campoHora local · UTC−3 · Status: Ativo
Matheus Nugas

Nota de campo 01 · AWS

PDFs com Chromium headless na Lambda, sem sofrimento

Quebras de página, tamanho do pacote, memória, e por que a fila na frente do renderizador importa mais que o renderizador.

Arquivada Leitura 4 minLigada a Caso 01 · Real Estate Management Platform

Cada imóvel da plataforma podia gerar uma pilha de relatórios: um Homeowner Guidebook, um Asset Report, um Maintenance Report e mais alguns. Cada um puxava os dados direto do banco e os organizava num PDF com a marca do produto. Gerar um é fácil. Gerar todos de forma confiável, sob demanda, dentro de uma Lambda, é onde a tarde vai embora.

Esses relatórios não eram uma feature secundária. Eram argumento de venda. Proprietários guardavam como o registro da casa, e corretores usavam durante a venda do imóvel. Uma página quebrada ou uma seção faltando aparecia justamente para as pessoas que o produto queria impressionar.

A estrutura

Um template HTML por tipo de relatório, preenchido com dados do banco, entregue ao Chromium headless e impresso em PDF. O caminho inteiro cabe numa tela:

import chromium from '@sparticuz/chromium-min';
import puppeteer from 'puppeteer-core';

const browser = await puppeteer.launch({
  args: chromium.args,
  // Pacote do Chromium no S3, na mesma região da função
  executablePath: await chromium.executablePath(process.env.CHROMIUM_PACK_URL),
  headless: true,
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.emulateMediaType('print');
const pdf = await page.pdf({ format: 'Letter', printBackground: true });
await browser.close();

A parte difícil é tudo que fica em volta dessas linhas.

Papel não é tela

Uma página web rola para sempre. Um PDF tem bordas, e todo relatório também precisava de uma versão para imprimir que funcionasse numa impressora de casa. A maior parte do trabalho foi ensinar os templates onde uma página termina:

  1. Quebras de página. Uma linha de tabela cortada no meio, ou um título de seção sozinho no pé da página, parece erro mesmo com os dados certos. Seções começam numa página nova, e o que precisa ficar junto diz isso explicitamente.
  2. Cabeçalhos que se repetem. Tabelas longas ocupam várias páginas, e quem está na página quatro ainda precisa saber o que cada coluna significa.
  3. Estilos de impressão. Margens, tamanho da página e fundos são definidos para o papel, não para a janela do navegador onde o template foi desenhado.

Quase tudo vira algumas linhas de CSS de impressão:

@page {
  size: Letter;
  margin: 0.6in;
}
.report-section {
  break-before: page;
}
.card,
tr {
  break-inside: avoid;
}
thead {
  display: table-header-group;
}
h2,
h3 {
  break-after: avoid;
}

A outra regra: tudo que o template precisa vai junto com ele. Uma Lambda quase não tem fontes de sistema, então um template que depende de uma cai numa fonte padrão. Fontes, logos e estilos vão empacotados ou embutidos no template, e o PDF sai igual em qualquer ambiente onde for renderizado.

Colocando o Chromium dentro da Lambda

Tamanho do pacote. Um build do Chromium não cabe com folga ao lado do seu código. Um zip de Lambda tem limite rígido de 250 MB descompactado, somando a função e todas as layers, então as respostas de sempre são uma layer ou uma imagem de container. Nós fomos por outro caminho: o pacote comprimido do Chromium fica num bucket S3 na mesma região das funções, e o @sparticuz/chromium-min baixa ele para o /tmp quando a função inicia a frio. O bundle da função continua minúsculo, atualizar o Chromium é subir um pacote novo em vez de republicar todas as funções, e como o bucket fica colado na Lambda, o download quase não aparece. Invocações quentes nem baixam, porque o binário ainda está no /tmp. Os cold starts ficaram rápidos e o caminho de render ficou enxuto.

Memória. O Chromium pede muito mais que um handler comum, e na Lambda memória também compra CPU: a CPU é alocada em proporção à memória, chegando ao equivalente a uma vCPU inteira em 1.769 MB. Uma função de render com pouca memória não só corre o risco de estourar, ela renderiza devagar. O README do @sparticuz/chromium sugere no mínimo 512 MB e recomenda 1.600 MB ou mais. É por aí que se começa, e depois você mede com o relatório mais pesado, não com o médio.

Cold starts. Abrir o navegador é a parte lenta de uma invocação fria. Isso não é problema enquanto ninguém estiver esperando por ela, o que nos leva à feature de verdade.

A fila é a feature de verdade

Renderizar é lento e chega em rajadas, então nada que o usuário toca chama o render diretamente. A API coloca uma mensagem no SQS e responde na hora; os workers renderizam, guardam o arquivo no S3 e enviam por e-mail.

APISQSChromium workerS3SendGrid
Fig. 01 · Do diagrama completo do Caso 01

A fila é o que torna as falhas entediantes:

  1. Retry de graça. Se um render falha ou estoura o tempo, a mensagem volta a ficar visível e outro worker pega. O visibility timeout da fila precisa ser maior que o timeout da função, senão um render lento é pego duas vezes enquanto a primeira tentativa ainda está rodando.
  2. Retry precisa ser seguro. Cada job grava numa chave derivada do relatório, não num nome aleatório, então a segunda tentativa sobrescreve a primeira em vez de criar um duplicado ou mandar dois e-mails do mesmo arquivo.
  3. Uma dead-letter queue pega o resto. Depois de algumas tentativas, a mensagem vai para uma DLQ em vez de ficar em loop para sempre. Essa mensagem guarda tudo que é preciso para entender a falha e reprocessar depois que o bug for corrigido.
  4. Picos viram fila. Quando muitos relatórios são pedidos ao mesmo tempo, eles esperam a vez em vez de bater no banco e no renderizador todos juntos.

Da próxima vez

Eu testaria os próprios PDFs, não só o código que gera eles. Renderizar cada tipo de relatório com dados de exemplo no CI e comparar cada página com uma versão que se sabe correta. Quebra de página é o tipo de bug que code review não enxerga: o diff parece inofensivo, e o problema só aparece na página quatro de um relatório longo.

Arquivada emAWSLambdaPuppeteerSQS

Matheus Nugas

Engenheiro fullstack sênior construindo sistemas orientados a eventos na AWS. Mais sobre mim.