はじめに

こんにちは。MIU AI戦略本部でAIエンジニアをしている田中宏樹です。

LLMを活用したアプリケーションでは、長時間の思考を必要とするタスクが複数組み合わさることで、処理時間が伸びてしまうケースが多くあります。

本記事では、こうした長時間処理を必要とするアプリケーションをAzure上で構築した際に採用したアーキテクチャや工夫点をご紹介します。

対象読者

  • 長い処理時間を要するアプリケーションを作りたい人
  • 非同期ジョブ型アプリケーションをAzureでデプロイしたい人
  • 非同期ジョブの実行に適しているAzureサービスが何かを知りたい人

背景

LLMの長いレスポンスをタブを開いたまま待ちたくない

私が所属するチームではAIの技術調査の一環として、超長尺の文章作成を行うWebアプリケーションを開発しています。

Server Action で LLM を同期実行する従来構成のアーキテクチャ図
LLM 完了までブラウザが応答待ちになる従来構成

このアプリケーションはNext.jsで構築されており、ブラウザからServer Actionを呼び出し、その内部でLLMによる文章生成が行われる構成となっていました。

しかし、生成結果を後続の入力として利用するというアプリケーションの特性上、LLMの実行を並列化するのが難しく、ユーザーがタブを開いたまま実行を待たなくてはいけないという課題がありました。

本来であれば、ユーザーはLLMの処理を開始した後にタブを閉じて別の作業を行い、完了後に結果を確認できる状態が理想です。

そこで、LLMの処理をジョブ化して非同期実行する仕組みが必要になりました。

Azureを採用したことによるメリット

今回のアプリケーションでは、Azure OpenAI Service経由でLLMを利用していました。そのため、アプリケーションのデプロイ先もAzureに統一することとしました。

これにより、Azureリソース間の認証をManaged Identityで行えるようになり、アプリケーション全体の認証を一元化することが可能になりました。

このようなジョブ形式の構成では、実行主体が増えることで認証情報の管理が複雑になりがちです。

しかしManaged Identityを利用することで、DBアクセスからLLM呼び出しまで、パスワードやAPIキーを環境変数に埋め込むことなく認証を行えます。

Managed Identityの詳細については、技術選定のセクションで詳しく紹介します。

技術選定

フロント + ジョブ実行基盤の選定

本章では、ジョブ化を実現するためのAzureリソースについて比較検討した結果を示します。

ジョブ実行をクラウド上で実現するためには、フロントエンドに即座にレスポンスを返し、それをトリガーとして非同期でLLMを実行する仕組みが必要でした。

その上で、最優先とした条件は 「長時間処理との相性」 です。

LLM単体の文章生成には数分〜数十分かかる場合があり、それらを組み合わせたワークフロー全体では数十分〜数時間ほどかかっていました。

そのため、それらのジョブをタイムアウトなく実行できることをリソースの必須条件として比較検討しました。

なお、以下は2026年4月時点の情報に基づく比較です。各サービスの仕様は変更される可能性があるため、最新情報は各公式ドキュメントを参照してください。

 

リソース
長時間処理との相性
特徴と評価

  • 直列・並列・リトライといったワークフロー制御が標準機能として揃っている
  • 既存ワークフローをステップ単位の関数に書き直す必要がある

  • HTTP接続を保持せず、ジョブとして非同期に実行できる
  • フロントのContainer Appと同じ環境で動かせる

  • エージェントホスティング基盤として優秀。LLMトレーシングがOpenTelemetryベースで組み込み済み
  •  動的なエージェント挙動が主用途のため、決定的な LLM 逐次実行のジョブ化には旨味が薄い
Azure App Service

  • 安定したWebホスティング基盤で短時間応答のフロントに向いている
  • HTTPサーバー前提のため、今回のジョブ実行用途には向いていない

 

比較の結果、候補に上がったのはDurable FunctionsとContainer Apps Jobsの2つでした。

Azure Durable Functions

Azure Durable Functionsは、Azure Functionsをベースにしたワークフロー実行基盤で、複数の処理を状態付きでオーケストレーションできるサービスです。

オーケストレーター関数を中心に複数の処理を順序立てて実行できるとともに、内部的に状態を永続化する仕組みを持っているため、長時間にわたる処理でも途中状態を保持したまま再開可能という特徴もあります。

また、外部イベントの待機やリトライ、分岐、並列実行といった制御フローをコードで表現できるため、複雑な非同期ワークフローの構築に適しています。

Durable Functions のドキュメント – Azure Durable | Microsoft Learn

Container Apps Jobs

Azure Container Apps Jobsは、コンテナを単位としてバッチ処理や非同期ジョブを実行できるマネージドサービスです。

任意のコンテナを実行できるため、言語や実行環境に制約がなく、既存の処理をそのままジョブとして実行できます。

ジョブはトリガーによって起動され、処理が完了すると終了するシンプルな実行モデルであるため、長時間にわたる処理をそのまま1つのジョブとして扱いやすいです。

さらに、CPUやメモリの割り当ても柔軟に設定できるため、LLMのような計算負荷の高い処理とも相性が良いです。

Azure Container Apps のジョブ | Microsoft Learn

今回採用したサービス

検討した結果、我々のチームでは以下の理由でContainer Apps Jobsを採用しました。

  • LLM監視の自由度: コンテナ上に任意のトレーシングツール(Langfuse等)を組み込み、LLM入出力を詳細に監視できる
  • ゼロスケールによるコスト低減: ジョブ非実行時はレプリカ数がゼロになり課金が発生しない
  • ジョブ基盤のマネージド化: リトライ・タイムアウト・ログ収集がビルトインで、自前実装が不要
  • 環境の共有: フロントのContainer Appと同じContainer Apps Environmentで動くため、ネットワーク・ログ・シークレット基盤を共通化できる

Durable Functionsはワークフロー制御が標準機能として揃っている一方で、既存ワークフローをステップ単位の関数に書き直す必要があり、移行コストが見合いませんでした。

Managed Identityによるパスワードレス認証

続いて、「Azureを採用したことによるメリット」の章で触れた認証の一元化について、具体的な構成を紹介します。

本アプリケーションでは、リソース間の認証にMicrosoft Entra ID(旧Azure AD)のManaged Identityを採用しました。

Managed Identityは、Azureの仮想マシンやアプリホスティング基盤に割り当てることができるIDであり、そのIDに対してロールベースでアクセス権限を設定できます。これにより、従来のようにパスワードやAPIキーといった長期的な認証情報を管理する必要がなくなります。

今回の構成では、PostgreSQL Flexible ServerにおいてEntra ID認証を有効化し、パスワード認証を無効化しています。

また、Key Vault(シークレット管理)・Azure Container Registry(ACR)(コンテナイメージの管理)・Storage(生成結果などのデータ保存)へのアクセスもすべてManaged Identityに統一しました。これにより、アプリケーションコードや環境変数にシークレットを埋め込むことなく、安全に各リソースへアクセスできるようになっています。

さらに、CI/CDにおいても、GitHub ActionsのOIDC Federated Credentialsを利用することで、Azure側にシークレットを保存せずに認証を行う構成としました。これにより、リポジトリに長期的な認証情報を保持する必要がなくなり、セキュリティリスクの低減と運用負荷の軽減を両立しています。

このようにManaged Identityを中心に据えた構成にすることで、Azureリソース間の認証においては長期的な認証情報を一切持たずに運用できるようになりました。

アーキテクチャの全体像

今回採用したアーキテクチャの全体像を示します。

Container Apps Jobs で LLM 推論を非同期化したアーキテクチャ図
Container Apps Jobs に LLM 処理を切り離した採用構成

ブラウザからのリクエストはContainer Apps(Next.js Web App)で受け、生成タスクをPostgreSQLに記録したのちに、Container Apps Jobs(Worker)を起動します。

Workerを起動したら、その完了を待たずに即座にレスポンスを返すので、ブラウザはLLMの実行完了を待つ必要がありません。

WorkerはContainer Apps JobsをManual Triggerで動かしていて、Container Apps(Next.js Web App)からAzure SDK経由で直接起動しています。

さらに、ブラウザとWorkerは直接接続せずに、PostgreSQLだけを介してやり取りする形にしました。

WebSocketやSSEで進捗を双方向に流す選択肢もありましたが、「ブラウザを閉じても処理を続けたい」「結果も進捗も永続化したい」という要件から、WorkerはDBに書くだけ、ブラウザはDBを読むだけ、というシンプルな構造にしています。

そのため、ブラウザ側のポーリングは処理中の間だけ動き、完了すれば自動で止まるようにしています。

この設計のおかげで、フロント・Worker・DBを疎結合に保つことが可能となりました。

また、ユーザーがタブを閉じて翌日戻ってきても結果がそのまま残っているという体験も実現できました。

工夫点

ここでは、Azureへのデプロイや実装周りで工夫した点についてお話ししていきます。

ポーリング + visibilitychangeでの結果確認

ジョブをバックエンドに切り離して非同期で実行する場合、フロントエンドからWorkerの完了をどう検知するかが課題になります。

今回はシンプルなポーリング方式を採用しました。

useEffect(() => {
  if (!status || !POLLING_STATUSES.has(status)) return;

  let isFetching = false;
  const fetchLatest = async () => {
    if (isFetching) return;
    isFetching = true;
    try {
      const data = await getDetail(id);
      if (!data) return;
      setData(parseData(data));
    } finally {
      isFetching = false;
    }
  };

  const interval = setInterval(fetchLatest, POLLING_INTERVAL_MS);
  const onVisible = () => {
    if (document.visibilityState === "visible") fetchLatest();
  };
  document.addEventListener("visibilitychange", onVisible);

  return () => {
    clearInterval(interval);
    document.removeEventListener("visibilitychange", onVisible);
  };
}, [status, id]);

ポイントは2つです。

  • ステータスが処理中の間だけポーリングする: 完了後は無駄なリクエストを発生させない
  • visibilitychange イベントでタブ復帰時に即時フェッチ: タブに戻ったときに最大ポーリング間隔分の待ちが発生するため、visibilitychange で即座にフェッチし、この待ち時間を解消している

LLMワークフローの生成時間が数分〜数十分単位なので、WebSocketのようなリアルタイム通知は不要と判断し、シンプルなポーリングを採用しました。ポーリング間隔は、サーバー負荷とUXのバランスで決めています。

パスワードレス認証下でのDBマイグレーション

本プロジェクトではORMにPrismaを採用しており、アプリ実行時は、PrismaのDriver Adapter機能を使用しています。

これは、Prismaに pg などのNode.jsライブラリを組み込める拡張ポイントです。

pg には「接続時に動的にパスワードを取得するコールバック機能」があり、ここにEntra IDトークン取得処理を組み込むことで、接続のたびに新しいトークンでPostgreSQLに接続しています。

一方、DBマイグレーション(スキーマ変更)はCDパイプラインの中で prisma migrate deploy コマンドを実行する形で行っています。

ここで問題になるのが、このCLIコマンドは独立したバイナリとして動作するため、アプリ内で設定したDriver Adapterを認識できない点です。そのため動的なトークン取得ができず、DATABASE_URL にトークンを自分で埋め込んで渡す必要があります。

そこで、マイグレーション実行前にEntra IDトークンを取得し、DATABASE_URL のパスワード部分に注入するラッパースクリプトを作成しました。

// scripts/migrate-deploy.ts
async function main() {
  if (process.env.AZURE_CLIENT_ID && process.env.DATABASE_URL) {
    const credential = new DefaultAzureCredential({
      managedIdentityClientId: process.env.AZURE_CLIENT_ID,
    });
    const tokenResponse = await credential.getToken(
      "https://ossrdbms-aad.database.windows.net/.default",
    );

    const url = new URL(process.env.DATABASE_URL);
    url.password = tokenResponse.token;
    process.env.DATABASE_URL = url.toString();
  }

  execSync("npx prisma migrate deploy", {
    stdio: "inherit",
    env: process.env,
  });
}

Managed Identityで取得した短命のEntra IDトークンをパスワードとして DATABASE_URL に埋め込むことで、prisma migrate deploy のようなCLIツールでもパスワードレスで動かせます。

PostgreSQLだけでなく、Entra ID認証に対応した他のAzureリソースでも同じ手法が使えます。

参考: How to connect to an Azure-hosted managed identities postgres server from a node app using the Prisma ORM

まとめ

LLMワークフローのように処理時間が長いアプリケーションでは、「ブラウザから処理を切り離す」ことが重要です。

本記事で紹介したAzure Container Apps Jobs + Managed Identityの構成により、以下を実現できました。

  • タブを閉じても処理が継続する: Server Actionで即座にレスポンスを返し、Container Apps Jobsでバックエンド処理を非同期実行
  • アプリケーション全体の認証を統一: Managed Identity + Entra IDで、DBアクセスからLLM呼び出しまでキーレスで実現

同じような課題に取り組む方の参考になれば幸いです。

参考