Lighthouse en Azure Functions

Uno de los escenarios con el que me ha tocado jugar, antes de las merecidas vacaciones de este año, ha sido con la herramienta Lighthouse y ver cómo alojar la misma en la nube, con el objetivo de poder automatizar su llamada con múltiples webs cada cierto tiempo. Hoy quiero compartir contigo cómo montar este escenario con Azure Logic Apps y Azure Functions.

Lighthouse en Azure Functions

Lo primero que necesitamos es alojar en algún sitio la herramienta Lighthouse. Esta tiene una librería en Node.js con la que podemos programar las llamadas a esta herramienta, en lugar de la linea de comandos o la extensión que acompaña a los navegadores. Para poder usarla necesitamos apoyarnos en algún navegador y al querer alojarlo en una Azure Function no podemos entrar e instalarlo sin más. Es por este tipo de escenarios que Google proporciona una herramienta más llamada Puppeter, que nos permite lanzar Chrome «sin tener Chrome», que es justo lo que necesitamos. El código sería parecido al siguiente:

const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
module.exports = async function(context, req) {
	const url = req.query.url || (req.body && req.body.url);
	context.log(`Analyzing URL ${url}`);
	// Use Puppeteer
	//https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#puppeteerlaunchoptions
	const browser = await puppeteer.launch({
		headless: true,
		timeout: 0,
		args: [ '--no-sandbox' ]
	});
	// Lighthouse will analyze the URL using puppeter
	const { lhr } = await lighthouse(url, {
		maxWaitForFcp: 30 * 1000,
		disableDeviceEmulation: true,
		port: new URL(browser.wsEndpoint()).port,
		output: 'json',
		logLevel: 'info',
		onlyCategories: [ 'seo', 'performance', 'accessibility', 'best-practices' ] //just analyze what you need
	});
	let result = {
		performance: lhr.categories.performance.score,
		accessibility: lhr.categories.accessibility.score,
		seo: lhr.categories.seo.score,
		// pwa: lhr.categories.pwa.score,
		bestpractices: lhr.categories['best-practices'].score
	};
	context.log(`result for ${url} is ${JSON.stringify(result)}`);
	browser.disconnect();
	await browser.close();
	context.res = {
		status: result.performance != null ? 200 : 500,
		body: result
	};
};

En el vamos a utilizar dos librerías: puppeter y lighthouse. El objetivo del código es que cada vez que le llegue una URL obtenga las puntuaciones de las categorías Performance, Accessibility, SEO y Best Practices. He dejado comentada la puntuación de Progressive Web App simplemente para que veas que es posible elegir solo algunas, según necesites.

Sin embargo, antes de desplegar la función en Azure necesitamos hacer una modificación en la forma de despliegue como se indica en el artículo de Anthony Chu, program manager de Azure Functions:

{
    "azureFunctions.deploySubpath": ".",
    "azureFunctions.projectLanguage": "JavaScript",
    "azureFunctions.projectRuntime": "~3",
    "debug.internalConsoleOptions": "neverOpen",
    "azureFunctions.scmDoBuildDuringDeployment": true
}

Lo importante aquí es sobre todo la última linea: azureFunctions.scmDoBuildDuringDeployment a true. Esto lo que significa es que no instalaremos las librerías en local y luego subiremos el paquete a Azure sino que haremos que la instalación se produzca en destino ya que de esta manera al intentar instalar puppeter descargará la versión del Chromium que necesita para el entorno en destino. Si estás usando Visual Studio Code, puedes desplegar de manera sencilla este código con la extensión de Azure Functions.

Por último, podría generar una Logic App con estos conectores, que me permitirá recuperar de un Excel las URLs que quiero analizar y otro Excel de salida con el resultado para estas:

Analyze-Webs Logic App

Nota: Si te has pegado con Lighthouse en el pasado sabrás que este no se lleva bien con el paralelismo y puede provocar errores e incluso hacer mix de ciertos valores que va recolectando, por lo que se aconseja su uso en secuencial o bien en diferentes procesos. Cuando trabajar con Logics Apps y For eachs dentro de este servicio la configuración por defecto es paralelizar dichos bucles. Por supuesto, es posible controlar la concurrencia en las settings del foreach y poner este a 1:

Setting del for each de la Logic App

Este fue mi primer intento, haciendo uso del modo consumo de Azure Functions. Si bien es cierto que con muchas de las URLs que probé funciona a la perfección hay algunas que se le resisten un poco y puede hacer que nuestro flujo falle. No olvides que este tier tiene máquinas solamente con 1,5GB de memoria y 100 de ACU (más información en este enlace), se puede ver sobrepasado con estas tareas, y hacer que tarden más de lo previsto, lo cual puede provocar también que alcancemos el límite de espera de las Logic Apps.

Azure Logic Apps – Error por timeout

El código lo tienes en mi GitHub.

Lighthouse en Azure Functions Premium y Docker

La segunda opción es desplegarlo en otro tipo de máquinas con una infraestructura más potente, que es utilizando el plan Premium. En este caso he tenido que modificar ligeramente el código anterior. Lo primero que hice fue añadirle la capacidad de desplegarlo en un contenedor de Docker. Para ello, al igual que hice en el artículo sobre Azure Functions en Kubernetes, he lanzado este comando sobre el directorio:

func init --docker-only

Este me generará un Dockerfile en la raíz del proyecto, además del .dockerignore con lo necesario para contenerizarlo y poder ejecutarlo en este tier Premium. Sin embargo, cuando lanzo la instalación de las dependencias durante la creación de la imagen no descarga de manera correcta Chromium, por lo que lo he incluido como parte de la generación de esta:

# To enable ssh & remote debugging on app service change the base image to the one below
# FROM mcr.microsoft.com/azure-functions/node:3.0-appservice
FROM mcr.microsoft.com/azure-functions/node:3.0
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
    AzureFunctionsJobHost__Logging__Console__IsEnabled=true \
    CHROMIUM_PATH=/usr/bin/chromium
COPY . /home/site/wwwroot
RUN apt-get update && apt-get -y install chromium && \
    cd /home/site/wwwroot && \
    npm install

Como ves, además de instalar el paquete chromium también añado una variable de entorno llamada CHROMIUM_PATH que contiene la ruta de este ejecutable. Con él puedo modificar el código fuente de la función para que esta sepa dónde está este archivo, en puppeteer.launch, y que todo funcione correctamente:

const lighthouse = require('lighthouse');
const puppeteer = require('puppeteer');
module.exports = async function(context, req) {
	const url = req.query.url || (req.body && req.body.url);
	context.log(`Analyzing URL ${url}`);
	// Use Puppeteer
	//https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#puppeteerlaunchoptions
	const browser = await puppeteer.launch({
		headless: true,
		timeout: 0,
		args: [ '--no-sandbox' ],
		executablePath: process.env.CHROMIUM_PATH
	});
	// Lighthouse will analyze the URL using puppeter
	const { lhr } = await lighthouse(url, {
		maxWaitForFcp: 30 * 1000,
		disableDeviceEmulation: true,
		port: new URL(browser.wsEndpoint()).port,
		output: 'json',
		logLevel: 'info',
		onlyCategories: [ 'seo', 'performance', 'accessibility', 'best-practices' ] //just analyze what you need
	});
	let result = {
		performance: lhr.categories.performance.score,
		accessibility: lhr.categories.accessibility.score,
		seo: lhr.categories.seo.score,
		// pwa: lhr.categories.pwa.score,
		bestpractices: lhr.categories['best-practices'].score
	};
	context.log(`result for ${url} is ${JSON.stringify(result)}`);
	browser.disconnect();
	await browser.close();
	context.res = {
		status: result.performance != null ? 200 : 500,
		body: result
	};
};

En este caso será necesario generar la imagen y desplegarla en algún registro. Para hacerlo sencillo, he utilizado mi cuenta de Docker Hub y he generado la imagen de la siguiente manera:

docker build -t 0gis0/lighthouse-on-functions
docker push 0gis0/lighthouse-on-functions

Una vez que tenemos la imagen lista, ya podemos dar de alta todos los recursos que necesitamos:

#Variables
RESOURCE_GROUP_NAME="AzureFunctionsContainers"
LOCATION="northeurope"
STORAGE_ACCOUNT="funcindockerstore"
PREMIUM_PLAN_NAME="myPremiumPlan"
FUNCTION_APP_NAME="dockerizedlighthouse"
RUNTIME="node"
DOCKER_IMAGE="0gis0/lighthouse-on-functions"
#Azure Login
az login
#Create resource group
az group create -n $RESOURCE_GROUP_NAME -l $LOCATION
#Create a storage account
az storage account create --name $STORAGE_ACCOUNT --location $LOCATION --resource-group $RESOURCE_GROUP_NAME --sku Standard_LRS
#Create a Premium Azure Function Plan
az functionapp plan create --resource-group $RESOURCE_GROUP_NAME --name $PREMIUM_PLAN_NAME --location $LOCATION --number-of-workers 1 --sku EP1 --is-linux
#Create an Azure Function App
az functionapp create --name $FUNCTION_APP_NAME --storage-account $STORAGE_ACCOUNT --resource-group $RESOURCE_GROUP_NAME --plan $PREMIUM_PLAN_NAME --runtime $RUNTIME --deployment-container-image-name $DOCKER_IMAGE --functions-version 2
#Get Azure Storage connection string
STORAGE_CONNECTION_STRING=$(az storage account show-connection-string --resource-group $RESOURCE_GROUP_NAME --name $STORAGE_ACCOUNT --query connectionString --output tsv)
#Add the connection string to AzureWebJobsStorage
az functionapp config appsettings set --name $FUNCTION_APP_NAME --resource-group $RESOURCE_GROUP_NAME --settings AzureWebJobsStorage=$STORAGE_CONNECTION_STRING

La Logic App sería exactamente la misma, con mejores tiempos de ejecución y mayor capacidad para las páginas más exigentes 🙂 El código también lo tienes en mi GitHub.

¡Saludos!