Node.js 用 Azure Functions のパフォーマンスとスケーリングを最適化する

TypeScriptや Node.jsを使ってAzure Functionsを開発する際は、関数がどのように機能し、そのパフォーマンスが関数アプリのスケールにどのように影響するかを理解する必要があります。 この理解は、高性能なアプリを設計する際には特に重要です。 関数アプリの設計、作成、設定で考慮すべき主な要素は、水平スケーリング、スループット性能設定、そして Node.js特化の最適化です。

水平スケーリング

既定では、Azure Functionsはアプリケーションの負荷を自動的に監視し、必要に応じて Node.js のホスト インスタンスを作成します。 Azure Functionsは、メッセージの年齢やQueueTriggerのキューサイズなど、インスタンスを追加するタイミングを決めるために、さまざまなトリガータイプの組み込みの閾値を用いています。 これらの閾値は設定できません。 詳細については、「 Azure Functions でのイベントドリブン スケーリング」を参照してください。

Node.js 関数アプリのスケーリングを向上させるには、以下の順に従う方法があります:

  1. まずはデフォルト設定から始め、非同期I/Oパターンを使ってコードを最適化しましょう。
  2. ワーカープロセス数を増やす前に、まずスケールアウト(インスタンスを増やす)してください。
  3. FUNCTIONS_WORKER_PROCESS_COUNTは各インスタンスのCPU飽和がロードテストで確認された後にのみ増加します。
  4. I/Oに限定されるワークロードについては、ワーカー数を控えめに抑え、ブロッキングコールや依存関係の遅延を減らすことに注力しましょう。
  5. 変更ごとに再テストを行い、スループット、レイテンシ、エラー率を監視して改善点を検証します。

スループット パフォーマンスの向上

デフォルトの構成はほとんどのAzure Functionsアプリケーションに適しています。 ただし、ワークロード プロファイルに基づいて構成を採用することで、アプリケーションのスループットのパフォーマンスを向上させることができます。 最初の手順では、実行しているワークロードの種類を理解します。

ワークロードの種類 関数アプリの特性 Examples
I/O バウンド •アプリは、多くの同時呼び出しを処理する必要があります。
• アプリは、ネットワーク呼び出しやディスクの読み取り/書き込みなど、多数の I/O イベントを処理します。
• Web API
• データベース運用
・ファイル処理
CPU 制約 ・アプリは画像処理などの長時間の計算を行います。
・アプリはデータ変換や複雑な計算を行います。
• データ処理
・コンテンツ変換
• アルゴリズムの実行

実際の関数ワークロードは通常I/OとCPUに縛られる混合なので、現実的な本番負荷でアプリをプロファイリングしてください。 以下のセクションでは、具体的な最適化について説明します。

非同期/待機パターン

Node.js イベント駆動型でブロッキングしないI/Oモデルを構築しており、I/Oバウンド操作において軽量かつ効率的に対応できます。 大量のI/Oイベントを処理するアプリケーションやI/Oバウンドのアプリケーションでは、適切な非同期/待機パターンを使うことでパフォーマンスを大幅に向上させることができます。

  • 並列操作には Promise.all() を使う:複数の独立した非同期操作がある場合は、順次ではなく並列で実行してください。
  • イベントループのブロックを避ける:非同期関数で同期演算を使わないでください。
  • 大規模なデータにストリーミングを使う:大量データセットをメモリにロードするのではなく、ストリームで処理する。
  • 適切なエラー処理を実装する:try/catchブロックを使用し、Promiseの拒否処理を行います。

以下の例は、 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();
}

複数の言語ワーカー プロセスを使用する

既定では、すべての Functions ホスト インスタンスには 1 つの言語ワーカー プロセスがあります。 FUNCTIONS_WORKER_PROCESS_COUNT アプリケーション設定を使用して、ホストあたりのワーカー プロセスの数を増やすことができます (最大 10)。 Azure Functions、これらのワーカー間で同時関数呼び出しを均等に分散しようとします。

Warning

FUNCTIONS_WORKER_PROCESS_COUNT 設定を使用するときには注意が必要です。 同じインスタンスで複数のプロセスを実行すると、予期しない動作が発生し、関数の読み込み時間が長くなる可能性があります。 この設定を使うと、 パッケージファイルから実行 することでこれらのデメリットを相殺できます。

CPUに縛られたアプリでは、言語ワーカーの数を関数アプリごとに利用可能なコア数と同じかそれ以上に設定します。 詳細については、「 使用可能なインスタンス SKU」を参照してください。

I/Oバウンドのアプリは、利用可能なコア数を超えてワーカープロセスの数を増やすことで恩恵を受けるかもしれません。 ワーカーの数が多すぎると、必要なコンテキスト スイッチの数が増えるので、全体的なパフォーマンスに影響する可能性があることに注意してください。

イベントループ最適化

Node.js I/O操作を処理するためにシングルスレッドのイベントループを使用します。 コードがイベントループとどのように相互作用するかを最適化することはパフォーマンスにとって非常に重要です。

イベントループを自由に保ちましょう

大量の計算を行う同期ループはイベントループをブロックし、他の呼び出し処理を妨げます。 代わりに、大規模なデータセットをバッチごとに分割し、 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));
    }
}

イベントループの遅延を監視する

イベントループキューでタスクがどれくらい待つかを測定するには、以下のパターンを用いてください。

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

メモリ管理

効率的なメモリ使用は、特にコールドスタートやスケーリングに関してAzure Functionsのパフォーマンスに不可欠です。 メモリ使用を最適化する際は、以下の一般的なガイドラインに従ってください:

  • グローバル変数の最小化:グローバルスコープ内の重い物体はコールドスタート時間に影響を与えます。
  • コネクションプーリングを活用してください:データベース接続やHTTPクライアントを再利用してください。
  • リソースを解放する: 接続を適切に閉じ、タイマーを解除しましょう。
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
});

コードの最適化

アプリをバンドルしたり、怠惰な読み込み戦略を用いたり、データ構造を最適化したりすることで、さらにコードを最適化できます。

依存関係をバンドルしましょう

esbuild、webpack、rollupなどのバンドルツールを使って、関数アプリをより少ないファイルにまとめましょう。 バンドルはコールドスタート時にランタイムが読み込むファイル数を減らし、特に依存関係ツリーが大きいアプリでは起動時間が直接改善されます。 詳細については、 展開前のバンドルをご覧ください。

モジュールロード時間の最小化

グローバルスコープで重いモジュールをロードすると、すべてのインポートが最初のインヴォーク前に実行されるため、コールドスタート時間が長くなります。 動的インポートを使って、モジュールが実際に必要になるまで読み込みを遅らせる:

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

効率的なデータ構造を使う

頻繁にアイテムを調べる必要がある場合は、MapではなくArray.find()を使いましょう。 Map は、配列の走査では O(n) であるのに対し、O(1) の検索を実現します:

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

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

モニタリングとプロファイリング

関数ハンドラー内で実行タイミングを追跡するために context.log() を使いましょう。 カスタムメトリクスや依存関係の追跡には、 Application Insights Node.js SDKを直接ご利用ください。 詳細は「 カスタムデータ追跡」をご覧ください。

以下の例は実行時間とメモリ使用量を記録しています。

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

これらの推奨に従うことで、Node.js Azure Functions アプリケーションのパフォーマンスとスケーラビリティを大幅に向上させることができます。