Optimiser les performances et la mise à l’échelle pour Node.js Azure Functions

Lorsque vous développez pour Azure Functions en utilisant TypeScript et Node.js, vous devez comprendre comment vos fonctions fonctionnent et comment ces performances influencent la mise en valeur de votre application de fonctions. Cette compréhension est d’autant plus importante lors de la conception d’applications très performantes. Les principaux facteurs à considérer lors de la conception, de l’écriture et de la configuration de vos applications de fonctions sont l’échelle horizontale, les configurations de performance en débit et les optimisations spécifiques à Node.js.

Scalabilité horizontale

Par défaut, Azure Functions surveille automatiquement la charge sur votre application et crée davantage d’instances d’hôte pour Node.js si nécessaire. Azure Functions utilise des seuils intégrés pour différents types de déclencheurs afin de décider quand ajouter des instances, comme l’âge des messages et la taille de la file d’attente pour QueueTrigger. Vous ne pouvez pas configurer ces seuils. Pour plus d’informations, consultez Mise à l’échelle basée sur les événements dans Azure Functions.

Pour améliorer la mise à l’échelle de votre application Node.js fonction, suivez cette approche dans l’ordre suivant :

  1. Commencez par les paramètres par défaut et optimisez d’abord votre code en utilisant des motifs d’E/S asynchrones.
  2. Augmentez le nombre d’instances (scale out) avant d’augmenter le nombre de processus de travail.
  3. Augmentez FUNCTIONS_WORKER_PROCESS_COUNT uniquement après que les tests de charge indiquent que le processeur est saturé sur chaque instance.
  4. Pour les charges de travail liées à l’E/S, gardez le nombre de travailleurs prudent et concentrez-vous sur la réduction des appels bloquants et de la latence de dépendance.
  5. Retestez après chaque changement et surveillez le débit, la latence et les taux d’erreur pour valider les améliorations.

Amélioration des performances du débit

Les configurations par défaut conviennent à la plupart des applications Azure Functions. Toutefois, vous pouvez améliorer les performances du débit de vos applications en utilisant des configurations basées sur votre profil de charge de travail. La première étape consiste à comprendre le type de charge de travail que vous exécutez.

Type de charge de travail Caractéristiques de l'application fonctionnelle Examples
Lié aux E/S • L’application doit gérer de nombreux appels simultanés.
• L’application traite un grand nombre d’événements d’E/S, tels que les appels réseau et les lectures/écritures de disque.
• API web
• Opérations de base de données
• Traitement de fichiers
Limité par le processeur • Application effectue des calculs de longue durée, tels que le traitement d’images.
• Application effectue des transformations de données ou des calculs complexes.
• Traitement des données
• Transformation de contenu
• Exécution d’algorithmes

Comme les charges de travail réelles sont généralement un mélange d’E/S et de CPU, profilez l’application sous des charges de production réalistes. Les sections suivantes couvrent des optimisations spécifiques :

Modèles Async/await

Node.js est construit sur un modèle d’E/S piloté par des événements et non bloquant, ce qui le rend léger et efficace pour les opérations liées aux E/S. Pour les applications qui traitent un grand nombre d’événements d’E/S ou qui sont limitées par des E/S, vous pouvez améliorer significativement les performances en utilisant des schémas asynchrones/await appropriés :

  • Utilisation Promise.all() pour les opérations parallèles : Lorsque vous avez plusieurs opérations asynchrones indépendantes, exécutez-les en parallèle plutôt que séquentiellement.
  • Évitez de bloquer la boucle d’événements : N’utilisez pas d’opérations synchrones dans les fonctions asynchrones.
  • Utilisez le streaming pour de grandes données : Traitez de grands ensembles de données en utilisant des flux plutôt que de tout charger en mémoire.
  • Mettez en place une gestion correcte des erreurs : utilisez des blocs try/catch et gérez les refus de Promesse.

L’exemple suivant démontre des opérations asynchrones parallèles avec 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();
}

Utiliser plusieurs processus de travail linguistique

Par défaut, chaque instance d’hôte Functions dispose d’un seul processus worker de langage. Vous pouvez augmenter le nombre de processus de travail par hôte (jusqu’à 10) à l’aide du FUNCTIONS_WORKER_PROCESS_COUNT paramètre d’application. Azure Functions tente ensuite de distribuer uniformément des appels de fonction simultanés entre ces travailleurs.

Warning

Utilisez le paramètre FUNCTIONS_WORKER_PROCESS_COUNT avec précaution. Plusieurs processus s’exécutant dans la même instance peuvent entraîner un comportement imprévisible et augmenter les temps de chargement des fonctions. Si vous utilisez ce paramètre, lancer depuis un fichier package peut compenser ces inconvénients.

Pour les applications limitées par le CPU, réglez le nombre de travailleurs du langage égal ou supérieur au nombre de cœurs disponibles par application fonctionnelle. Pour en savoir plus, consultez Les références SKU d’instance disponibles.

Les applications limitées par les E/S pourraient également tirer parti d’une augmentation du nombre de processus de travail au-delà du nombre de cœurs disponibles. N’oubliez pas que la définition du nombre de travailleurs trop élevé peut affecter les performances globales en raison du nombre accru de commutateurs de contexte requis.

Optimisation des boucles d’événements

Node.js utilise une boucle d’événements à thread unique pour gérer les opérations d’E/S. Optimiser la façon dont votre code interagit avec la boucle d’événements est crucial pour la performance.

Gardez la boucle d’événements libre

Les boucles synchrones effectuant des calculs lourds bloquent la boucle d’événements et empêchent le traitement d’autres invocations. À la place, divisez les grands ensembles de données en lots et obtenez le contrôle entre les lots avec 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));
    }
}

Surveiller le délai de boucle d’événements

Utilisez le schéma suivant pour mesurer combien de temps les tâches attendent dans la file d’attente de la boucle d’événements :

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`);
    });
}

Gestion de la mémoire

Une utilisation efficace de la mémoire est essentielle pour les performances d’Azure Functions, notamment en ce qui concerne les démarrages à froid et la mise à l’échelle. Suivez ces directives générales pour optimiser votre utilisation de la mémoire :

  • Minimiser les variables globales : Les objets lourds à portée globale affectent les temps de démarrage à froid.
  • Utilisez le pooling de connexions : réutilisez les connexions de base de données et les clients HTTP.
  • Nettoyez les ressources : Fermez correctement les connexions et effacez les minuteurs.
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
});

Optimisation du code

Vous pouvez optimiser davantage votre code en regroupant votre application, en adoptant une stratégie de chargement paresseuse et en optimisant vos structures de données.

Regroupez vos dépendances

Utilisez des outils de regroupement comme esbuild, webpack ou rollup pour combiner votre application fonctionnelle en moins de fichiers. Le regroupement réduit le nombre de fichiers que le temps d’exécution charge lors du démarrage à froid, ce qui améliore directement le temps de démarrage, en particulier pour les applications avec de grands arbres de dépendances. Pour plus d’informations, voir Bundle avant le déploiement.

Minimiser le temps de chargement des modules

Le chargement de modules lourds à l’échelle globale augmente le temps de démarrage à froid car chaque importation s’exécute avant la première invocation. Utilisez des importations dynamiques pour différer le chargement jusqu’à ce que le module soit réellement nécessaire :

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

Utiliser des structures de données efficaces

Lorsque vous devez chercher fréquemment des éléments, utilisez un Map au lieu de Array.find(). A Map fournit des recherches O(1) comparées à O(n) pour le balayage de tableaux :

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

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

Surveillance et profilage

Utilisez-le context.log() pour suivre le timing d’exécution dans votre gestionnaire de fonctions. Pour des métriques personnalisées et le suivi des dépendances, utilisez directement le SDK Application Insights Node.js . Pour plus d’informations, voir Suivre les données personnalisées.

L’exemple suivant enregistre la durée d’exécution et l’utilisation de la mémoire :

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
});

En suivant ces recommandations, vous pouvez améliorer significativement les performances et la scalabilité de vos Node.js Azure Functions applications.