EXERCISE 60分

「サーバーレスのパターンは学んだ。ここからは実際の業務シナリオで設計してみよう。正解は1つじゃない。トレードオフを考え抜いて、自分なりの設計を導き出すことが大事だ」と佐藤CTOが演習の開始を告げた。

推定所要時間: 60分


Mission 1: 画像処理パイプラインの設計

ECサイトに出品者が商品画像をアップロードする機能を設計してください。

要件:

  • ユーザーが画像をアップロードすると、3種類のサイズ(サムネイル 150x150、中 600x600、大 1200x1200)にリサイズ
  • 不適切な画像(暴力、アダルト)を自動検出してブロック
  • 処理完了後に出品者へメール通知
  • 1日あたり平均 5,000 枚、ピーク時 50,000 枚/時
  • 処理完了まで30秒以内

設計すべきもの:

  1. アーキテクチャ図
  2. 使用する AWS サービスの選定理由
  3. エラーハンドリング戦略
  4. コスト見積もり(概算)
解答例

アーキテクチャ

graph TD
    USER["ユーザー"]:::user --> APIGW(["API Gateway"]):::apigw
    APIGW --> PRESIGN["Lambda<br/>(Presigned URL生成)"]:::lambda
    PRESIGN --> S3[("S3<br/>(raw-images/)")]:::storage
    S3 -->|S3 Event| ORCH["Lambda<br/>(Orchestrator)"]:::lambda
    ORCH --> SF(["Step Functions"]):::step

    SF --> REK["1. Rekognition<br/>(コンテンツ検査)"]:::ai
    REK -->|安全| PARALLEL["並列実行"]:::parallel
    REK -->|不適切| BLOCK["SNS通知 + S3削除"]:::error

    PARALLEL --> R1["2. Lambda<br/>(リサイズ 150x150)"]:::lambda
    PARALLEL --> R2["3. Lambda<br/>(リサイズ 600x600)"]:::lambda
    PARALLEL --> R3["4. Lambda<br/>(リサイズ 1200x1200)"]:::lambda

    R1 --> FINISH["5. Lambda<br/>(DB更新 + SES通知)"]:::lambda
    R2 --> FINISH
    R3 --> FINISH

    classDef user fill:#ECF0F1,stroke:#7F8C8D,color:#2C3E50
    classDef apigw fill:#E74C3C,stroke:#A93226,color:#FFFFFF
    classDef lambda fill:#F39C12,stroke:#B7770D,color:#FFFFFF
    classDef storage fill:#3498DB,stroke:#21618C,color:#FFFFFF
    classDef step fill:#9B59B6,stroke:#6C3483,color:#FFFFFF
    classDef ai fill:#1ABC9C,stroke:#148F77,color:#FFFFFF
    classDef parallel fill:#2ECC71,stroke:#1E8449,color:#FFFFFF
    classDef error fill:#E74C3C,stroke:#A93226,color:#FFFFFF

サービス選定理由

サービス用途選定理由
API GatewayPresigned URL 発行REST API のエンドポイント
S3画像保存イベント通知機能、高耐久性
Step Functionsワークフロー管理並列実行、エラーハンドリング
Lambdaリサイズ処理短時間処理、並列スケール
Rekognitionコンテンツ検査マネージドAI、API呼び出しだけで利用可能
SESメール通知低コストなメール送信
DynamoDB画像メタデータ低レイテンシ、サーバーレス

エラーハンドリング

// Step Functions の定義(ASL)
const definition = {
  StartAt: "ContentModeration",
  States: {
    ContentModeration: {
      Type: "Task",
      Resource: "arn:aws:lambda:...:content-moderation",
      Retry: [
        {
          ErrorEquals: ["ServiceUnavailable"],
          IntervalSeconds: 2,
          MaxAttempts: 3,
          BackoffRate: 2.0,
        },
      ],
      Catch: [
        {
          ErrorEquals: ["UnsafeContentError"],
          Next: "HandleUnsafeContent",
        },
        {
          ErrorEquals: ["States.ALL"],
          Next: "HandleError",
        },
      ],
      Next: "ParallelResize",
    },
    ParallelResize: {
      Type: "Parallel",
      Branches: [
        { StartAt: "ResizeSmall", States: { /* ... */ } },
        { StartAt: "ResizeMedium", States: { /* ... */ } },
        { StartAt: "ResizeLarge", States: { /* ... */ } },
      ],
      Next: "UpdateAndNotify",
    },
    HandleUnsafeContent: {
      Type: "Task",
      Resource: "arn:aws:lambda:...:delete-and-notify",
      End: true,
    },
    HandleError: {
      Type: "Task",
      Resource: "arn:aws:lambda:...:error-handler",
      End: true,
    },
    UpdateAndNotify: {
      Type: "Task",
      Resource: "arn:aws:lambda:...:update-db-and-notify",
      End: true,
    },
  },
};

コスト見積もり(月間 150,000 枚)

項目月額
Lambda(リサイズ 3関数 x 150K、512MB、3秒)~$3.75
Step Functions(150K 実行、5ステップ)~$3.75
Rekognition(DetectModerationLabels 150K)~$150.00
S3(保存 + リクエスト)~$5.00
SES(150K メール)~$15.00
合計~$177.50

Rekognition が最もコストが大きい。閾値判定を先にLambdaで簡易チェックし、疑わしいもののみ Rekognition に送ることでコスト削減が可能。


Mission 2: リアルタイム通知システムの設計

SNS のようなソーシャルアプリで、フォロワーへの投稿通知をリアルタイムに配信するシステムを設計してください。

要件:

  • ユーザーが投稿するとフォロワー全員にプッシュ通知
  • 人気ユーザーは最大100万フォロワー
  • 通知の遅延は5秒以内
  • 通知の重複を防止
  • 配信状況を追跡可能
解答例

アーキテクチャ

graph TD
    API["投稿API"]:::api --> APIGW(["API Gateway"]):::apigw
    APIGW --> SAVE["Lambda<br/>(投稿保存)"]:::lambda
    SAVE --> EB{{"EventBridge"}}:::eventbridge
    EB --> FANOUT["Lambda<br/>(Fan-out Controller)"]:::lambda
    FANOUT --> SQS(["SQS<br/>(バッチキュー)<br/>バッチサイズ: 500件"]):::queue
    FANOUT --> DYNAMO[("DynamoDB<br/>(配信ステータス追跡)")]:::db
    SQS --> WORKER["Lambda<br/>(通知送信Worker)"]:::lambda
    WORKER --> PUSH["SNS Platform /<br/>WebSocket"]:::notify

    classDef api fill:#ECF0F1,stroke:#7F8C8D,color:#2C3E50
    classDef apigw fill:#E74C3C,stroke:#A93226,color:#FFFFFF
    classDef lambda fill:#F39C12,stroke:#B7770D,color:#FFFFFF
    classDef eventbridge fill:#8E44AD,stroke:#6C3483,color:#FFFFFF
    classDef queue fill:#E67E22,stroke:#D35400,color:#FFFFFF
    classDef db fill:#1ABC9C,stroke:#148F77,color:#FFFFFF
    classDef notify fill:#2ECC71,stroke:#1E8449,color:#FFFFFF

Fan-out 戦略

// Fan-out Controller: フォロワーを分割してSQSに投入
export const fanOutHandler = async (event: EventBridgeEvent<"PostCreated", PostDetail>) => {
  const { postId, userId } = event.detail;

  // フォロワーをページネーションで取得
  let lastKey: string | undefined;
  do {
    const followers = await getFollowers(userId, lastKey, 500);

    // 500件ずつSQSにバッチ送信
    await sqs.send(new SendMessageBatchCommand({
      QueueUrl: process.env.NOTIFICATION_QUEUE_URL,
      Entries: [{
        Id: `batch-${lastKey ?? "first"}`,
        MessageBody: JSON.stringify({
          postId,
          followerIds: followers.items.map(f => f.followerId),
        }),
        MessageGroupId: postId,  // FIFO キューで重複防止
        MessageDeduplicationId: `${postId}-${lastKey ?? "first"}`,
      }],
    }));

    lastKey = followers.lastKey;
  } while (lastKey);
};

// 通知送信 Worker
export const notificationWorker = async (event: SQSEvent) => {
  for (const record of event.Records) {
    const { postId, followerIds } = JSON.parse(record.body);

    // バッチで通知送信
    const results = await Promise.allSettled(
      followerIds.map((id: string) => sendPushNotification(id, postId))
    );

    // 配信ステータスを記録
    const succeeded = results.filter(r => r.status === "fulfilled").length;
    const failed = results.filter(r => r.status === "rejected").length;

    await dynamodb.send(new UpdateCommand({
      TableName: process.env.DELIVERY_TABLE,
      Key: { postId, batchId: record.messageId },
      UpdateExpression: "SET succeeded = :s, failed = :f, processedAt = :t",
      ExpressionAttributeValues: {
        ":s": succeeded,
        ":f": failed,
        ":t": new Date().toISOString(),
      },
    }));
  }
};

重複防止

手法実装
SQS FIFOMessageDeduplicationId でメッセージレベルの重複排除
DynamoDB 条件付き書き込み配信済みフォロワーのチェック
冪等キー${postId}-${followerId} で通知の一意性を保証

Mission 3: マイクロサービス間の Saga パターン実装

ECサイトの注文処理で、以下のサービスをまたぐトランザクションを Saga パターンで実装してください。

処理フロー:

  1. 在庫確認・引当(Inventory Service)
  2. 決済処理(Payment Service)
  3. 配送手配(Shipping Service)
  4. 注文確定通知(Notification Service)

要件:

  • 各ステップの失敗時に補償トランザクションを実行
  • 決済失敗時は在庫を戻す
  • 配送手配失敗時は決済をキャンセルし、在庫を戻す
  • 全体の処理状態を追跡可能
解答例

Step Functions による Orchestration Saga

// Step Functions 定義(CDK)
import * as sfn from "aws-cdk-lib/aws-stepfunctions";
import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks";

const reserveInventory = new tasks.LambdaInvoke(this, "ReserveInventory", {
  lambdaFunction: inventoryFn,
  payload: sfn.TaskInput.fromObject({
    action: "RESERVE",
    "orderId.$": "$.orderId",
    "items.$": "$.items",
  }),
  resultPath: "$.inventoryResult",
});

const releaseInventory = new tasks.LambdaInvoke(this, "ReleaseInventory", {
  lambdaFunction: inventoryFn,
  payload: sfn.TaskInput.fromObject({
    action: "RELEASE",
    "orderId.$": "$.orderId",
    "reservationId.$": "$.inventoryResult.Payload.reservationId",
  }),
});

const processPayment = new tasks.LambdaInvoke(this, "ProcessPayment", {
  lambdaFunction: paymentFn,
  resultPath: "$.paymentResult",
});

const refundPayment = new tasks.LambdaInvoke(this, "RefundPayment", {
  lambdaFunction: paymentFn,
  payload: sfn.TaskInput.fromObject({
    action: "REFUND",
    "paymentId.$": "$.paymentResult.Payload.paymentId",
  }),
});

const arrangeShipping = new tasks.LambdaInvoke(this, "ArrangeShipping", {
  lambdaFunction: shippingFn,
  resultPath: "$.shippingResult",
});

const sendNotification = new tasks.LambdaInvoke(this, "SendNotification", {
  lambdaFunction: notificationFn,
});

// 補償トランザクションチェーン
const compensatePaymentAndInventory = refundPayment
  .next(releaseInventory)
  .next(new sfn.Fail(this, "OrderFailed-Shipping", {
    cause: "Shipping arrangement failed",
  }));

const compensateInventory = releaseInventory
  .next(new sfn.Fail(this, "OrderFailed-Payment", {
    cause: "Payment processing failed",
  }));

// メインフロー
const definition = reserveInventory
  .addCatch(new sfn.Fail(this, "OrderFailed-Inventory"), {
    errors: ["States.ALL"],
  })
  .next(
    processPayment.addCatch(compensateInventory, {
      errors: ["States.ALL"],
      resultPath: "$.error",
    })
  )
  .next(
    arrangeShipping.addCatch(compensatePaymentAndInventory, {
      errors: ["States.ALL"],
      resultPath: "$.error",
    })
  )
  .next(sendNotification)
  .next(new sfn.Succeed(this, "OrderCompleted"));

状態追跡

// 各 Lambda で処理状態を DynamoDB に記録
const updateSagaState = async (
  orderId: string,
  step: string,
  status: "PENDING" | "COMPLETED" | "COMPENSATED" | "FAILED"
) => {
  await dynamodb.send(new UpdateCommand({
    TableName: process.env.SAGA_TABLE,
    Key: { orderId },
    UpdateExpression: "SET #step = :status, updatedAt = :now",
    ExpressionAttributeNames: { "#step": step },
    ExpressionAttributeValues: {
      ":status": status,
      ":now": new Date().toISOString(),
    },
  }));
};
ステップ正常処理補償トランザクション
在庫引当RESERVERELEASE
決済処理CHARGEREFUND
配送手配ARRANGECANCEL
通知送信SEND(補償不要)

Mission 4: サーバーレス vs コンテナの判断

以下の3つのワークロードについて、サーバーレス(Lambda)とコンテナ(ECS Fargate)のどちらが適切か判断し、理由を説明してください。

ワークロード:

  1. レポート生成: 毎日深夜に100件のPDFレポートを生成。1件あたり2-5分。
  2. WebSocket チャット: 常時1,000-5,000接続。メッセージ送受信のレイテンシ要件は100ms以内。
  3. Webhook 受信: 外部SaaSからのWebhookを受信して処理。1日平均500件、ピーク時30件/秒。不定期。
解答例

ワークロード 1: レポート生成 → コンテナ(ECS Fargate)推奨

判断基準評価
実行時間2-5分 → Lambda 可能だが上限に近い
頻度日次バッチ → コンテナ向き
並列度100件 → Fargate タスクで並列化
メモリPDF生成は大量のメモリが必要な場合がある
# ECS Scheduled Task
Resources:
  ReportTask:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Cpu: "1024"
      Memory: "2048"
      ContainerDefinitions:
        - Name: report-generator
          Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/report-gen:latest"
          Environment:
            - Name: BATCH_SIZE
              Value: "10"

  ReportSchedule:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: "cron(0 15 * * ? *)"  # JST 0:00
      Targets:
        - Arn: !GetAtt ECSCluster.Arn
          EcsParameters:
            TaskCount: 10  # 10タスク × 10件ずつ = 100件

ワークロード 2: WebSocket チャット → コンテナ(ECS Fargate)推奨

判断基準評価
接続形態常時接続 → Lambda不向き
レイテンシ100ms → コールドスタートNG
状態管理WebSocket 接続状態の保持が必要
コスト常時負荷 → コンテナの方が安い

API Gateway WebSocket API + Lambda でも実装可能だが、接続管理の複雑さとレイテンシ要件を考慮するとコンテナが適切。

ワークロード 3: Webhook 受信 → サーバーレス(Lambda)推奨

判断基準評価
実行時間短時間(数秒)
頻度不定期、ゼロになる時間帯あり
スケールピーク30件/秒 → Lambda で十分
コスト月間15,000件 → Lambda が圧倒的に安い
// Lambda Webhook Handler
export const handler = async (event: APIGatewayProxyEvent) => {
  // 署名検証
  const signature = event.headers["x-webhook-signature"];
  if (!verifySignature(event.body!, signature!)) {
    return { statusCode: 401, body: "Invalid signature" };
  }

  // 冪等性チェック
  const webhookId = event.headers["x-webhook-id"];
  const exists = await checkIdempotencyKey(webhookId!);
  if (exists) {
    return { statusCode: 200, body: "Already processed" };
  }

  // 処理(SQS に委譲して即座に応答)
  await sqs.send(new SendMessageCommand({
    QueueUrl: process.env.WEBHOOK_QUEUE_URL,
    MessageBody: event.body!,
    MessageDeduplicationId: webhookId,
  }));

  return { statusCode: 202, body: JSON.stringify({ received: true }) };
};

判断サマリ

ワークロード推奨主な理由
レポート生成コンテナ長時間実行、高メモリ要件
WebSocket チャットコンテナ常時接続、低レイテンシ要件
Webhook 受信サーバーレス不定期、短時間、低コスト