Optimiza el rendimiento y el escalado para Node.js Azure Functions

Al desarrollar para Azure Functions usando TypeScript y Node.js, necesitas entender cómo funcionan tus funciones y cómo ese rendimiento afecta a cómo escala tu aplicación de funciones. Esta comprensión es aún más importante al diseñar aplicaciones de alto rendimiento. Los principales factores que debes tener en cuenta al diseñar, escribir y configurar tus aplicaciones de función son el escalado horizontal, las configuraciones de rendimiento y capacidad de procesamiento, y las optimizaciones específicas de Node.js.

Escalado horizontal

De forma predeterminada, Azure Functions supervisa automáticamente la carga en la aplicación y crea más instancias de host para Node.js según sea necesario. Azure Functions utiliza umbrales integrados para distintos tipos de desencadenador para decidir cuándo agregar instancias, como la antigüedad de los mensajes y el tamaño de la cola en el caso de QueueTrigger. No puedes configurar estos umbrales. Para más información, consulte Escalado controlado por eventos en Azure Functions.

Para mejorar el escalado de tu aplicación de Node.js función, sigue este enfoque:

  1. Empieza con la configuración predeterminada y optimiza tu código usando patrones de E/S asíncronos.
  2. Escale horizontalmente (añada más instancias) antes de escalar verticalmente el número de procesos de trabajo.
  3. Aumenta FUNCTIONS_WORKER_PROCESS_COUNT solo después de que las pruebas de carga muestren saturación de CPU en cada instancia.
  4. Para cargas de trabajo limitadas a E/S, mantenga el recuento de trabajadores conservador y céntrese en reducir las llamadas bloqueadas y la latencia de dependencias.
  5. Vuelve a probar después de cada cambio y monitoriza el rendimiento, la latencia y las tasas de error para validar mejoras.

Mejora del rendimiento

Las configuraciones predeterminadas son adecuadas para la mayoría de las aplicaciones de Azure Functions. Sin embargo, puede mejorar el rendimiento del flujo de trabajo de sus aplicaciones empleando configuraciones ajustadas a su perfil de carga de trabajo. El primer paso es comprender el tipo de carga de trabajo que está ejecutando.

Tipo de carga de trabajo Características de la aplicación de funciones Ejemplos
Limitado por E/S • La aplicación necesita controlar muchas invocaciones simultáneas.
• La aplicación procesa un gran número de eventos de E/S, como llamadas de red y lectura y escritura en disco.
• API Web
• Operaciones de bases de datos
• Procesamiento de archivos
Limitado por la CPU • App realiza cálculos de larga duración, como el procesamiento de imágenes.
• App realiza transformaciones de datos o cálculos complejos.
• Procesamiento de datos
• Transformación de contenido
• Ejecución de algoritmos

Como las cargas de trabajo reales de una función suelen ser una combinación de operaciones limitadas por E/S y por CPU, elabora un perfil de la aplicación con cargas de producción realistas. Las siguientes secciones cubren optimizaciones específicas:

Patrones asincrónicos y await

Node.js está construido sobre un modelo de E/S impulsado por eventos y no bloqueante, lo que lo hace ligero y eficiente para operaciones de E/S limitada. En las aplicaciones que procesan un gran número de eventos de E/S o están limitadas por la E/S, se puede mejorar significativamente el rendimiento mediante patrones adecuados de async/await:

  • Úsalos Promise.all() para operaciones paralelas: Cuando tengas múltiples operaciones asíncronas independientes, ejecútalas en paralelo en lugar de secuencialmente.
  • Evita bloquear el bucle de eventos: No uses operaciones síncronas en funciones asincrónicas.
  • Utiliza streaming para grandes datos: Procesa grandes conjuntos de datos usando flujos en lugar de cargar todo en la memoria.
  • Implementa un control de errores adecuado: Usa bloques try/catch y gestiona los rechazos de Promise.

El siguiente ejemplo demuestra operaciones asíncronas paralelas con Promise.all():

const { app } = require('@azure/functions');

app.http('optimizedHttpTrigger', {
    methods: ['GET', 'POST'],
    handler: async (request, context) => {
        // Good: Using async/await for HTTP calls
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();

        // Good: Parallel async operations
        const [user, orders] = await Promise.all([
            fetchUser(data.userId),
            fetchOrders(data.userId)
        ]);

        return { 
            jsonBody: { user, orders }
        };
    }
});

async function fetchUser(userId) {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    return await response.json();
}

async function fetchOrders(userId) {
    const response = await fetch(`https://api.example.com/orders?userId=${userId}`);
    return await response.json();
}
module.exports = async function (context, req) {
    // Good: Using async/await for HTTP calls
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();

    // Good: Parallel async operations
    const [user, orders] = await Promise.all([
        fetchUser(data.userId),
        fetchOrders(data.userId)
    ]);

    context.res = {
        body: { user, orders }
    };
};

async function fetchUser(userId) {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    return await response.json();
}

async function fetchOrders(userId) {
    const response = await fetch(`https://api.example.com/orders?userId=${userId}`);
    return await response.json();
}

Uso de procesos de trabajo de varios lenguajes

De forma predeterminada, cada instancia de host de Functions tiene un único proceso de trabajo de lenguaje. Puede aumentar el número de procesos de trabajo por host (hasta 10) mediante la configuración de la FUNCTIONS_WORKER_PROCESS_COUNT aplicación. Azure Functions luego intenta distribuir uniformemente las invocaciones simultáneas de funciones entre estos trabajadores.

Warning

Use la configuración FUNCTIONS_WORKER_PROCESS_COUNT con precaución. Ejecutar varios procesos en la misma instancia puede provocar un comportamiento impredecible y aumentar los tiempos de carga de la función. Si usas esta configuración, ejecutar desde un archivo de paquete puede compensar estos inconvenientes.

En el caso de las aplicaciones dependientes de CPU, establezca el número de trabajos de lenguaje para que sea igual o mayor que el número de núcleos disponibles por aplicación de funciones. Para más información, consulte SKU de instancia disponibles.

Las aplicaciones vinculadas a E/S también podrían beneficiarse de aumentar el número de procesos de trabajo más allá del número de núcleos disponibles. Tenga en cuenta que configurar el número de trabajadores demasiado alto puede afectar al rendimiento general debido al aumento del número de cambios de contexto necesarios.

Optimización de bucles de eventos

Node.js utiliza un bucle de eventos monohilo para gestionar las operaciones de E/S. Optimizar cómo tu código interactúa con el bucle de eventos es crucial para el rendimiento.

Mantén el bucle de eventos desbloqueado

Los bucles síncronos que realizan cálculos pesados bloquean el bucle de eventos e impiden que se procesen otras invocaciones. En su lugar, divide grandes conjuntos de datos en lotes y proporciona control entre lotes con setImmediate:

async function processDataNonBlocking(data: any[]) {
    const batchSize = 100;
    
    for (let i = 0; i < data.length; i += batchSize) {
        const batch = data.slice(i, i + batchSize);
        
        await Promise.all(
            batch.map(item => processItem(item))
        );
        
        // Allow event loop to process other tasks
        await new Promise(resolve => setImmediate(resolve));
    }
}

Monitorizar el retardo del bucle de eventos

Utiliza el siguiente patrón para medir cuánto tiempo las tareas esperan en la cola del bucle de eventos:

import { performance } from 'perf_hooks';

function measureEventLoopDelay(context: any) {
    const start = performance.now();
    setImmediate(() => {
        const delay = performance.now() - start;
        context.log(`Event loop delay: ${delay}ms`);
    });
}

Administración de memoria

El uso eficiente de la memoria es fundamental para el rendimiento de Azure Functions, especialmente en lo que respecta a los arranques en frío y el escalado. Sigue estas pautas generales al optimizar el uso de tu memoria:

  • Minimizar variables globales: Los objetos pesados en alcance global afectan los tiempos de arranque en frío.
  • Utiliza el pooling de conexiones: Reutiliza conexiones de bases de datos y clientes HTTP.
  • Limpiar recursos: Cierra correctamente las conexiones y borra temporizadores.
import { app, InvocationContext, Timer } from '@azure/functions';

// Good: Connection pooling
class DatabaseManager {
    private static instance: DatabaseManager;
    private connectionPool: any;
    
    private constructor() {
        this.connectionPool = createConnectionPool({
            max: 10,
            min: 2,
            idleTimeoutMillis: 30000
        });
    }
    
    public static getInstance(): DatabaseManager {
        if (!DatabaseManager.instance) {
            DatabaseManager.instance = new DatabaseManager();
        }
        return DatabaseManager.instance;
    }
    
    public async query(sql: string): Promise<any> {
        const client = await this.connectionPool.connect();
        try {
            return await client.query(sql);
        } finally {
            client.release();
        }
    }
}

async function timerTrigger(myTimer: Timer, context: InvocationContext): Promise<void> {
    const db = DatabaseManager.getInstance();
    const results = await db.query('SELECT * FROM users');
    
    context.log(`Processed ${results.length} users`);
}

app.timer('timerTrigger', {
    schedule: '0 */5 * * * *',
    handler: timerTrigger
});

Optimización de código

Puedes optimizar aún más tu código agrupando tu app, usando una estrategia de carga perezosa y optimizando tus estructuras de datos.

Agrupa tus dependencias

Usa herramientas de empaquetado como esbuild, webpack o rollup para combinar tu app de funciones en menos archivos. El empaquetado reduce el número de archivos que el entorno de ejecución carga durante el arranque en frío, lo que mejora directamente el tiempo de inicio, especialmente en aplicaciones con árboles de dependencias extensos. Para más información, consulta Empaquetar antes de la implementación.

Minimizar el tiempo de carga del módulo

Cargar módulos pesados en escala global aumenta el tiempo de inicio en frío porque cada importación se ejecuta antes de la primera invocación. Utiliza importaciones dinámicas para posponer la carga hasta que el módulo sea realmente necesario:

async function processData(data: any) {
    const { heavyFunction } = await import('heavy-library');
    return heavyFunction(data);
}

Utilizar estructuras de datos eficientes

Cuando necesites buscar objetos con frecuencia, usa un Map en lugar de Array.find(). Un Map proporciona búsquedas O(1), frente a O(n) para examinar una matriz:

const userMap = new Map();
const users = await fetchUsers();
users.forEach(user => userMap.set(user.id, user));

const user = userMap.get(userId); // O(1) lookup

Monitorización y perfilado

Úsalo context.log() para controlar el tiempo de ejecución dentro de tu gestor de funciones. Para métricas personalizadas y seguimiento de dependencias, utiliza directamente el SDK de Application Insights Node.js . Para más información, consulte Seguimiento de datos personalizados.

El siguiente ejemplo registra la duración de la ejecución y el uso de memoria:

import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';

async function trackedFunction(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    const startTime = Date.now();
    const memoryBefore = process.memoryUsage().heapUsed;

    try {
        const result = await processData(request);

        const duration = Date.now() - startTime;
        const memoryDelta = (process.memoryUsage().heapUsed - memoryBefore) / 1024 / 1024;

        context.log(`Execution time: ${duration}ms, Memory delta: ${memoryDelta.toFixed(2)}MB`);

        return { jsonBody: result };
    } catch (error) {
        context.error(`Function failed after ${Date.now() - startTime}ms`, error);
        throw error;
    }
}

app.http('trackedFunction', {
    methods: ['GET', 'POST'],
    handler: trackedFunction
});

Siguiendo estas recomendaciones, puedes mejorar significativamente el rendimiento y la escalabilidad de tus aplicaciones Node.js Azure Functions.