📖

Amplify Gen2 + SQS + Lambda + App Runner で LLM API のリトライ機構を実装した話

に公開

はじめに

LLM を使ったチャットアプリを運用していると、「App Runner が再起動したタイミングでユーザーのリクエストが消える」 という問題に直面しました。

本記事では、この問題を Amazon SQS + Lambda + App Runner の構成で解決した実装を紹介します。インフラは Amplify Gen2 (CDK) で定義し、LLM API は FastAPI (Python) で実装しています。

課題

従来のアーキテクチャ

React → App Runner (FastAPI) → Claude API → DynamoDB

問題点

問題 詳細
処理が消える App Runner の再起動/クラッシュ時、処理中リクエストが消失
リトライ機構がない 一時的な障害でも完全に失敗
障害復旧が困難 失敗リクエストの特定・再処理手段がない

LLM の処理は数十秒〜数分かかるため、その間に App Runner がスケールダウンや再デプロイで再起動すると、ユーザーの質問が回答されないまま消えてしまいます。

解決策

新アーキテクチャ

React
  ↓ POST
API Gateway
  ↓
SQS Queue ←─ 失敗したら自動リトライ(最大3回)
  ↓ トリガー
Lambda (TypeScript)
  ↓ HTTP POST /llm
App Runner (FastAPI)
  ↓
Claude API
  ↓
DynamoDB(結果書き込み)

※ 3回失敗 → DLQ(Dead Letter Queue)→ アラート

なぜこの構成?

評価項目 理由
既存コード活用 App Runner の LLM 処理ロジックをそのまま使用
実装コスト 新規は Lambda(薄いオーケストレーター)のみ
運用コスト SQS + Lambda はサーバーレスで低コスト
障害耐性 SQS 自動リトライ + DLQ で失敗リクエスト保持

実装

ディレクトリ構成

amplify/
├── backend.ts                    # SQS/Lambda/API Gateway 定義
└── functions/
    └── llm-worker/
        ├── resource.ts           # Lambda 定義
        ├── handler.ts            # Lambda ハンドラー
        └── package.json

# App Runner (別リポジトリ)
app-runner-llm-api/
└── src/
    └── app.py                    # /llm エンドポイント

1. 依存関係の追加

Amplify Gen2 で SQS + Lambda + API Gateway を使うには、CDK 関連パッケージが必要です。

# pnpm
pnpm add -D aws-cdk-lib constructs @types/aws-lambda

# npm
npm install --save-dev aws-cdk-lib constructs @types/aws-lambda

2. Lambda 関数の定義

// amplify/functions/llm-worker/resource.ts
import { defineFunction } from '@aws-amplify/backend';

export const llmWorker = defineFunction({
  name: 'llm-worker',
  entry: './handler.ts',
  timeoutSeconds: 300, // 5分(LLM処理時間考慮)
  memoryMB: 512,
});
// amplify/functions/llm-worker/package.json
{
  "name": "llm-worker",
  "type": "module",
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.700.0",
    "@aws-sdk/lib-dynamodb": "^3.700.0"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.145"
  }
}

3. backend.ts(SQS + Lambda + API Gateway)

// amplify/backend.ts
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { llmWorker } from './functions/llm-worker/resource';
import { PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Function } from 'aws-cdk-lib/aws-lambda';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as cdk from 'aws-cdk-lib';
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';

const backend = defineBackend({
  auth,
  data,
  llmWorker,
});

// ========================================
// SQS + Lambda 構成
// ========================================

const sqsStack = backend.createStack('LlmJobSqs');

// DLQ(Dead Letter Queue)
const llmJobDlq = new sqs.Queue(sqsStack, 'LlmJobDlq', {
  retentionPeriod: cdk.Duration.days(14),
});

// メインキュー
const llmJobQueue = new sqs.Queue(sqsStack, 'LlmJobQueue', {
  visibilityTimeout: cdk.Duration.minutes(6), // Lambda タイムアウト + バッファ
  retentionPeriod: cdk.Duration.days(7),
  deadLetterQueue: {
    queue: llmJobDlq,
    maxReceiveCount: 3, // 3回失敗で DLQ へ
  },
});

// Lambda に SQS トリガーを追加
const llmWorkerLambda = backend.llmWorker.resources.lambda as Function;
llmWorkerLambda.addEventSource(
  new lambdaEventSources.SqsEventSource(llmJobQueue, {
    batchSize: 1,
  }),
);

// 環境変数
llmWorkerLambda.addEnvironment(
  'APP_RUNNER_LLM_ENDPOINT',
  process.env.APP_RUNNER_LLM_ENDPOINT || '',
);

// DynamoDB アクセス権限
const chatMessageTable = backend.data.resources.tables['ChatMessage'];
const llmJobTable = backend.data.resources.tables['LlmJob'];

llmWorkerLambda.addEnvironment('CHAT_MESSAGE_TABLE_NAME', chatMessageTable.tableName);
llmWorkerLambda.addEnvironment('LLM_JOB_TABLE_NAME', llmJobTable.tableName);

llmWorkerLambda.addToRolePolicy(
  new PolicyStatement({
    actions: ['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:UpdateItem'],
    resources: [chatMessageTable.tableArn, llmJobTable.tableArn],
  }),
);

// ========================================
// API Gateway → SQS 連携
// ========================================

const apiGatewayRole = new Role(sqsStack, 'ApiGatewaySqsRole', {
  assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
llmJobQueue.grantSendMessages(apiGatewayRole);

const api = new apigateway.RestApi(sqsStack, 'LlmJobApi', {
  restApiName: 'LLM Job API',
  defaultCorsPreflightOptions: {
    allowOrigins: apigateway.Cors.ALL_ORIGINS,
    allowMethods: ['POST', 'OPTIONS'],
    allowHeaders: ['Content-Type', 'Authorization'],
  },
});

// SQS 統合
const sqsIntegration = new apigateway.AwsIntegration({
  service: 'sqs',
  path: `${cdk.Aws.ACCOUNT_ID}/${llmJobQueue.queueName}`,
  integrationHttpMethod: 'POST',
  options: {
    credentialsRole: apiGatewayRole,
    requestParameters: {
      'integration.request.header.Content-Type': "'application/x-www-form-urlencoded'",
    },
    requestTemplates: {
      'application/json': 'Action=SendMessage&MessageBody=$input.body',
    },
    integrationResponses: [
      {
        statusCode: '200',
        responseParameters: {
          'method.response.header.Access-Control-Allow-Origin': "'*'",
        },
        responseTemplates: {
          'application/json':
            '{"status": "accepted", "messageId": "$input.path(\'$.SendMessageResponse.SendMessageResult.MessageId\')"}',
        },
      },
    ],
  },
});

// POST /jobs エンドポイント
const jobsResource = api.root.addResource('jobs');
jobsResource.addMethod('POST', sqsIntegration, {
  methodResponses: [
    {
      statusCode: '200',
      responseParameters: {
        'method.response.header.Access-Control-Allow-Origin': true,
      },
    },
  ],
});

// Outputs
new cdk.CfnOutput(sqsStack, 'LlmJobApiEndpoint', {
  value: api.url,
  description: 'API Gateway endpoint',
});

4. Lambda ハンドラー

// amplify/functions/llm-worker/handler.ts
import type { SQSEvent } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';

const APP_RUNNER_LLM_ENDPOINT = process.env.APP_RUNNER_LLM_ENDPOINT || '';
const CHAT_MESSAGE_TABLE_NAME = process.env.CHAT_MESSAGE_TABLE_NAME || '';
const LLM_JOB_TABLE_NAME = process.env.LLM_JOB_TABLE_NAME || '';

const dynamoClient = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(dynamoClient);

interface LlmJobMessage {
  sessionId: string;
  jobId: string;
  messageId: string;
  userMessage: string;
}

interface LlmApiResponse {
  content: string;
  model?: string;
  usage?: { input_tokens: number; output_tokens: number };
}

// App Runner の /llm を呼び出す
async function callLlmApi(userMessage: string): Promise<LlmApiResponse> {
  const response = await fetch(`${APP_RUNNER_LLM_ENDPOINT}/llm`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userMessage }),
  });

  if (!response.ok) {
    throw new Error(`LLM API error: ${response.status}`);
  }

  return await response.json();
}

// DynamoDB 更新ヘルパー
async function updateLlmJobStatus(jobId: string, status: string, resultPayload?: object) {
  const updateExpression = ['#status = :status', 'updatedAt = :updatedAt'];
  const values: Record<string, unknown> = {
    ':status': status,
    ':updatedAt': new Date().toISOString(),
  };

  if (resultPayload) {
    updateExpression.push('resultPayload = :resultPayload');
    values[':resultPayload'] = JSON.stringify(resultPayload);
  }

  await docClient.send(
    new UpdateCommand({
      TableName: LLM_JOB_TABLE_NAME,
      Key: { id: jobId },
      UpdateExpression: `SET ${updateExpression.join(', ')}`,
      ExpressionAttributeNames: { '#status': 'status' },
      ExpressionAttributeValues: values,
    }),
  );
}

async function updateChatMessage(messageId: string, status: string, content?: string) {
  const updateExpression = ['#status = :status', 'updatedAt = :updatedAt'];
  const values: Record<string, unknown> = {
    ':status': status,
    ':updatedAt': new Date().toISOString(),
  };

  if (content) {
    updateExpression.push('content = :content');
    values[':content'] = content;
  }

  await docClient.send(
    new UpdateCommand({
      TableName: CHAT_MESSAGE_TABLE_NAME,
      Key: { id: messageId },
      UpdateExpression: `SET ${updateExpression.join(', ')}`,
      ExpressionAttributeNames: { '#status': 'status' },
      ExpressionAttributeValues: values,
    }),
  );
}

// Lambda ハンドラー
export const handler = async (event: SQSEvent): Promise<void> => {
  for (const record of event.Records) {
    const body: LlmJobMessage = JSON.parse(record.body);
    const { jobId, messageId, userMessage } = body;

    console.log(`Processing job: ${jobId}`);

    try {
      await updateLlmJobStatus(jobId, 'running');

      const llmResponse = await callLlmApi(userMessage);

      await updateChatMessage(messageId, 'completed', llmResponse.content);
      await updateLlmJobStatus(jobId, 'succeeded', {
        answer: llmResponse.content,
        model: llmResponse.model,
        usage: llmResponse.usage,
      });

      console.log(`Job completed: ${jobId}`);
    } catch (error) {
      console.error(`Job failed: ${jobId}`, error);

      await updateChatMessage(messageId, 'error', 'エラーが発生しました');
      await updateLlmJobStatus(jobId, 'failed');

      // 例外を投げて SQS にリトライさせる
      throw error;
    }
  }
};

5. App Runner (FastAPI) の /llm エンドポイント

# src/app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class LlmRequest(BaseModel):
    userMessage: str

class LlmResponse(BaseModel):
    content: str
    model: str | None = None
    usage: dict | None = None

@app.post("/llm", response_model=LlmResponse)
async def handle_llm(request: LlmRequest):
    """
    LLM 処理専用エンドポイント
    Lambda がこのエンドポイントを呼び出す。
    DB 書き込みは Lambda 側で行う。
    """
    try:
        llm_service = get_llm_service()  # 既存のLLMサービス
        response = await llm_service.generate(request.userMessage)

        return LlmResponse(
            content=response.content,
            model=response.model,
            usage=response.usage,
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

6. amplify.yml(ビルド設定)

version: 1
backend:
  phases:
    build:
      commands:
        - npm install
        # Lambda 関数の依存関係を個別にインストール
        - cd amplify/functions/llm-worker && npm install && cd ../../..
        - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
frontend:
  phases:
    build:
      commands:
        - npm run build
  artifacts:
    baseDirectory: dist
    files:
      - '**/*'

7. フロントエンド(React)

// API Gateway → SQS に送信
export const submitLlmJob = async (request: LlmJobRequest) => {
  const endpoint = import.meta.env.VITE_LLM_API_GATEWAY_ENDPOINT;

  const response = await fetch(`${endpoint}jobs`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      sessionId: request.sessionId,
      jobId: request.jobId,
      messageId: request.messageId,
      userMessage: request.prompt,
    }),
  });

  if (!response.ok) {
    throw new Error(`API Gateway error: ${response.status}`);
  }

  // 即座に 202 Accepted が返る
  // 実際の結果は DynamoDB をポーリングして取得
  return await response.json();
};

ハマりポイント・教訓

1. SQS キュー名の 80 文字制限

問題: queueName を明示的に指定すると、Amplify が環境名を付加して 80 文字を超えることがある。

解決: queueName は指定せず、CDK に自動生成させる。

// ❌ NG
const queue = new sqs.Queue(stack, 'LlmJobQueue', {
  queueName: 'llm-job-queue', // 80文字超過の可能性
});

// ✅ OK
const queue = new sqs.Queue(stack, 'LlmJobQueue', {
  // queueName は指定しない
});

2. Lambda 依存関係のインストール

問題: Amplify ビルド時に Lambda 関数の node_modules がインストールされない。

解決: amplify.yml で明示的に npm install を実行。

- cd amplify/functions/llm-worker && npm install && cd ../../..

3. Visibility Timeout の設定

問題: Lambda タイムアウト(5分)より Visibility Timeout が短いと、処理中にメッセージが再度 visible になりリトライが発生する。

解決: Visibility Timeout = Lambda タイムアウト + バッファ(1分)

const llmJobQueue = new sqs.Queue(sqsStack, 'LlmJobQueue', {
  visibilityTimeout: cdk.Duration.minutes(6), // Lambda 5分 + バッファ 1分
});

4. API Gateway エンドポイントの取得

デプロイ後、CloudFormation Outputs から取得:

aws cloudformation describe-stacks \
  --stack-name "amplify-xxx-LlmJobSqs-xxx" \
  --query "Stacks[0].Outputs[?OutputKey=='LlmJobApiEndpoint'].OutputValue" \
  --output text

リトライの動作

正常フロー

React → API Gateway → SQS → Lambda → App Runner /llm → DynamoDB
                              ↓
                        (成功) メッセージ削除

リトライフロー

SQS → Lambda → App Runner /llm
        ↓
      (失敗) 例外スロー
        ↓
SQS (Visibility Timeout 経過後)
        ↓
      リトライ 1回目 → 成功 → メッセージ削除

DLQ フロー

SQS → Lambda (失敗 1回目)
  ↓
リトライ 1 (失敗 2回目)
  ↓
リトライ 2 (失敗 3回目)
  ↓
DLQ へ移動 → CloudWatch アラート

まとめ

SQS + Lambda を挟むことで、以下のメリットが得られました:

Before After
App Runner 障害で処理消失 SQS がメッセージを保持、自動リトライ
リトライ手段なし 最大 3 回の自動リトライ
失敗リクエストが不明 DLQ で失敗リクエストを保持

Amplify Gen2 の CDK オーバーライド機能を使えば、SQS や API Gateway も同じリポジトリで管理できるので、インフラとアプリケーションの一貫性が保てます。

Discussion