LayerX エンジニアブログ

LayerX の エンジニアブログです。

どうするノイジーネイバー in Temporal

こんにちは、LayerX バクラク事業部 Platform Engineering 部 Enabling グループに新卒入社した shibutani と申します。

弊社ではLLMを用いたアプリケーション開発を行っており、その中で様々な技術スタックの検討を行っています。 今回はワークフローエンジンの一つであるTemporalと、マルチテナントシステムにおける典型的な課題であるノイジーネイバー問題について考えます。

Temporal

近年LLMを組み込んだアプリケーション開発が一般的になってきていますが、そういったプロダクトにおいては次のことを考える必要があります。

  • LLMのレスポンスを別のサービスに渡して処理をするようなシステム間の依存関係
  • LLMへのリクエスト失敗やレスポンスバリデーション結果に基づいた適切なリトライ処理
  • Headless Browserなど外部ツール呼び出しによるロングランニングタスク

このような個々のタスクの状態管理や依存関係の記述を行うことができるワークフローツールとしてTemporalがあります。

Temporalでは依存関係のある複数のタスクを一つのワークフローという単位で管理することができ、ワークフローの中で実行される各タスクはDurable、すなわちタスクの実行状況が記録され、万が一の障害などの場合にも適切に再開される仕組みが備わっています。

Temporalの仕組み自体はシンプルで、Temporal Server、Worker、Task Queueという3つの要素で構成されています。 まずクライアントとなるアプリケーションがTemporal Serverに対して実行したいワークフローとTask Queueを指定したリクエストを送信します。 リクエストを受け取ったTemporal ServerがTask Queueにジョブを追加し、Task Queueを監視しているWorkerが実際にワークフロージョブを実行します。

sequenceDiagram
    participant Client as Client<br/>(呼び出し元アプリケーション)
    participant TS as Temporal Server
    participant TQ as Task Queue
    participant Worker as Worker

    Client->>TS: 1. ワークフロー実行リクエスト<br/>(Workflow + Task Queue指定)
    TS->>TQ: 2. ワークフロージョブを投入
    Worker->>TQ: 3. ジョブをポーリング
    TQ->>Worker: 4. ワークフロージョブを配信
    Worker->>Worker: 5. ワークフローを実行
    Worker->>TS: 6. 実行結果を返却
    TS->>Client: 7. 結果を返却

ノイジーネイバー問題

このようなQueueを用いた構成を見た時に、弊社のようなマルチテナントシステムにおけるノイジーネイバー問題を考える必要に気づきました。

ノイジーネイバー問題とは、大量のリクエストを行うあるテナントによって、他のテナントの処理がブロッキングされる問題を指します。それによって一部のテナントでのリクエストのレスポンスタイムが長くなり、ユーザーの体験毀損につながります。

Temporalを用いたシステムの場合ですと、ある時刻にあるテナントから大量にワークフロージョブのリクエストが送信された場合、そのテナントにTask Queueが独占されてしまい、他のテナントがリクエストを実行できなくなってしまいます。(ネットワークの世界ではHead of Line Blockingというらしい)

すぐに思いつく解決策としては、各テナント専用のTask Queueを作成し、Workerがラウンドロビン方式で各Task QueueからPollingすることが考えられますが、Workerは起動時に単一のQueueを指定する必要があり、テナント数分のTask QueueとWorkerを適切に管理するのが難しいという問題があります。

そこで、シンプルな方法としてRate Limitと2つのTask Queueを組み合わせる方法を試してみます。

方法: Rate Limitと2つのTask Queue

  1. Rate Limiterを作成

    • 時間あたりのTask Queueへのワークフロージョブ投入回数に応じたRate Limiterを作成
  2. 2種類のTask QueueとWorkerを作成

    • 通常のTask QueueとしてNormalQueueとNormalWorker
    • リクエスト数の多いテナント用のTask QueueとしてLimitingQueueとLimitingWorker
  3. Temporal Serverへのリクエスト時にRate Limiterに問い合わせる

    • Rate Limitに抵触していないテナントのジョブはNotmalQueueへ投入
    • Rate Limitに抵触しているテナントのジョブはLimitingQueueへ投入

これによってノイジーテナントのリクエストは自身、もしくは他のノイジーテナントのリクエストによって律速され、他のテナントのリクエストが優先的に処理されます。

このようなRate Limitを行うClient Wrapperを書いてみました。

interface IRateLimiter {
  isRateLimited(tenantId: string): boolean;
  increaseCount(tenantId: string): void;
}
export class RateLimitWorkflowClient extends WorkflowClient {
  rateLimiter: IRateLimiter;
  constructor(rateLimiter: IRateLimiter, options?: WorkflowClientOptions) {
    super(options);
    this.rateLimiter = rateLimiter;
  }

  async start<T extends Workflow>(
    workflowTypeOrFunc: string | T,
    options: WorkflowStartOptions<T> & { tenantId: string }
  ): Promise<WorkflowHandleWithFirstExecutionRunId<T>> {
      // Rate Limitに抵触している場合はLimitingQueueを指定する
    if (options.tenantId && this.rateLimiter.isRateLimited(options.tenantId)) {
      options.taskQueue = `${options.taskQueue}-limiting`;
    }
    // Queueへの投入時にRate Limiterのカウントを増やす
    this.rateLimiter.increaseCount(options.tenantId);
    return super.start(workflowTypeOrFunc, options);
  }
}

実際にテストしてみます。

  • 条件
    • テナント設定
      • 通常テナント: 100個のジョブを実行する
      • 大量テナント: 1000個のジョブを実行する
      • 各テナント間は並列に動き、通常テナントは10msごとに、大量テナントは1msごとにワークフローリクエストを行う
      • 100リクエストを超えたテナントに対してRate Limitを行う
    • Temporal設定
      • 各ワークフローの実行時間は100ms
      • Workerにおけるワークフローの並列実行は無効化
      • Rate Limit無しの場合
        • 2つのWorkerが一つのQueueを担当する
      • Rate Limitありの場合
        • 1つのWorkerが通常のQueueを担当し、もう一つのWorkerがRate Limitされたジョブが投入されるQueueを担当する
  • 結果(Rate Limitしない場合)

      === Non Rate Limiting Test ===
    
      Sending 1000 requests from 大量テナント
      Sending 100 requests from 通常テナント
    
      ✅ All 1100 jobs submitted simultaneously
    
      ⏳ Waiting for completions...
    
      ✨ 通常テナント: ALL 100 jobs completed in 65.0s
    
      ✨ 大量テナント: ALL 1000 jobs completed in 65.0s
    
  • 結果(Rate Limitする場合)

      === Rate Limiting Test ===
    
      Sending 1000 requests from 大量テナント
      Sending 100 requests from 通常テナント
    
      ✅ All 1100 jobs submitted simultaneously
    
      ⏳ Waiting for completions...
    
      ✨ 通常テナント: ALL 100 jobs completed in 22.5s
    
      ✨ 大量テナント: ALL 1000 jobs completed in 96.6s
    

当然の結果ではありますが、事前にRate Limitを行い、Queueを分散させることによってHoLブロッキングを防ぐことができています。

まとめ

本記事では、LLMを活用したアプリケーション開発において有用なワークフローエンジンとしてTemporalを紹介し、特にマルチテナントシステムで課題になるノイジーネイバー問題に焦点を当ててお話しました。

本記事で紹介した方法は非常にシンプルな方法ですので、みなさんのオレオレ最強ノイジーネイバー対策を教えてください!