AWS SAMでハマった『循環参照』地獄から抜け出す方法

サーバーレスアプリケーションをAWS SAMで開発していると、次のような厄介なエラーに遭遇することがあります。

```bash
Error: Circular dependency between resources: [MyLambdaFunction, MyLambdaPermission, MyS3Bucket]
```

このエラー、特にS3バケットの通知設定でLambda関数を呼び出す構成で頻繁に発生します。今回はこの「循環参照地獄」から抜け出す効果的な解決策を実例とともに紹介します。

## 循環参照の原因

S3バケットからLambda関数を呼び出す設定を行うと、次のような循環依存関係が発生します：

```
S3バケット → Lambda通知設定 → Lambda関数 → Lambda実行権限 → S3バケット
```

例えば、以下のようなテンプレートを作成した場合を考えてみましょう：

```yaml
Resources:
  # S3バケットの定義
  ProcessingBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: my-processing-bucket
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: 's3:ObjectCreated:*'
            Function: !GetAtt ProcessingFunction.Arn

  # Lambda関数の定義
  ProcessingFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/processFile.handler
      Runtime: nodejs18.x
      Policies:
        - S3CrudPolicy:
            BucketName: !Ref ProcessingBucket
```

このテンプレートをデプロイしようとすると循環参照エラーが発生します。S3バケットはLambda関数を参照し、Lambda関数はS3バケットを参照しているためです。

## 解決策：テンプレートの分割

この問題を解決するシンプルな方法は、テンプレートを「インフラ用」と「アプリケーション用」の2つに分割することです。

### 1. インフラ用テンプレート (infra.yaml)

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: インフラリソース用スタック

Resources:
  ProcessingBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: my-processing-bucket
      # この時点では通知設定は含めない

Outputs:
  ProcessingBucketName:
    Value: !Ref ProcessingBucket
    Export:
      Name: ProcessingBucketName

  ProcessingBucketArn:
    Value: !GetAtt ProcessingBucket.Arn
    Export:
      Name: ProcessingBucketArn
```

### 2. アプリケーション用テンプレート (app.yaml)

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: アプリケーションリソース用スタック

Resources:
  # Lambda関数の定義
  ProcessingFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/processFile.handler
      Runtime: nodejs18.x
      Policies:
        - S3CrudPolicy:
            BucketName: !ImportValue ProcessingBucketName

  # S3バケット通知設定用のカスタムリソース
  S3NotificationFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/setupNotification.handler
      Runtime: nodejs18.x
      Policies:
        - AWSLambdaBasicExecutionRole
        - Statement:
            - Effect: Allow
              Action: s3:PutBucketNotificationConfiguration
              Resource: !ImportValue ProcessingBucketArn

  # 通知設定を行うカスタムリソース
  S3NotificationConfig:
    Type: Custom::S3Notification
    Properties:
      ServiceToken: !GetAtt S3NotificationFunction.Arn
      BucketName: !ImportValue ProcessingBucketName
      LambdaArn: !GetAtt ProcessingFunction.Arn
      Events: 
        - 's3:ObjectCreated:*'
```

### 3. 通知設定用のLambda関数実装例 (src/setupNotification.js)

```javascript
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = async (event, context) => {
  console.log('イベント:', JSON.stringify(event, null, 2));
  
  // カスタムリソースからのリクエストを処理
  if (event.RequestType === 'Delete') {
    // スタック削除時は通知設定をクリア
    return await sendResponse('SUCCESS', event);
  }
  
  // Create/Update時の処理
  try {
    const { BucketName, LambdaArn, Events } = event.ResourceProperties;
    
    // バケットの通知設定を更新
    const params = {
      Bucket: BucketName,
      NotificationConfiguration: {
        LambdaFunctionConfigurations: [
          {
            Events: Array.isArray(Events) ? Events : [Events],
            LambdaFunctionArn: LambdaArn
          }
        ]
      }
    };
    
    await s3.putBucketNotificationConfiguration(params).promise();
    console.log(`${BucketName}の通知設定を更新しました`);
    
    return await sendResponse('SUCCESS', event);
  } catch (error) {
    console.error('エラー:', error);
    return await sendResponse('FAILED', event, error.message);
  }
};

// CloudFormationにレスポンスを返す関数
async function sendResponse(status, event, reason = '') {
  const responseBody = JSON.stringify({
    Status: status,
    Reason: reason,
    PhysicalResourceId: event.LogicalResourceId,
    StackId: event.StackId,
    RequestId: event.RequestId,
    LogicalResourceId: event.LogicalResourceId
  });
  
  console.log('レスポンス:', responseBody);
  
  // 実際のCloudFormationへのレスポンス送信コードは省略
  // 本番環境では適切にHTTPSリクエストを送信する必要があります
  return {
    status: status,
    message: reason || 'SUCCESS'
  };
}
```

## デプロイ手順

1. インフラ用スタックを先にデプロイ
```bash
sam deploy --template-file infra.yaml --stack-name my-infra-stack
```

2. アプリケーション用スタックをデプロイ
```bash
sam deploy --template-file app.yaml --stack-name my-app-stack
```

## ポイント

- **スタック分割**：インフラリソース（S3バケット）とアプリケーションリソース（Lambda関数）を別々のスタックで管理します
- **クロススタック参照**：`!ImportValue`を使ってスタック間でリソースを参照します
- **カスタムリソース**：通知設定用のカスタムリソースを使用することで、循環参照を回避します
- **権限設定**：S3バケットの通知設定を変更するための適切なIAMポリシーを設定します

## まとめ

AWS SAMでの循環参照問題は、テンプレートを適切に分割することで解決できます。実際のプロジェクトではリソース間の依存関係を慎重に設計し、必要に応じてスタックを分割することが重要です。

このパターンはS3以外にも、DynamoDBストリームやSQSトリガーなど、様々なAWSサービス間の循環参照問題にも応用できます。AWS SAMでのサーバーレスアプリケーション開発がより円滑になれば幸いです！