🔧

Amplify バックエンドにおける循環参照エラーの回避策

に公開

はじめに

Amplify Gen2 を使用してバックエンドを構築する際、リソース間の依存関係が原因で循環参照エラーが発生することがあります。この記事では、特に S3 トリガー関数と他の関数/リソース間で発生しやすい循環参照エラーの原因と、resourceGroupName を用いた解決策について解説します。

循環参照エラーとは?

Amplify でリソースをデプロイする際、CloudFormation が使用されます。リソース間に循環依存が存在すると、CloudFormation はスタックの作成順序を決定できず、以下のようなエラーが発生します。

The CloudFormation deployment failed due to circular dependency found between nested stacks [storage0EC3F24A, function1351588B]
Resolution: If you are using functions then you can assign them to existing nested stacks that are dependent on functions or functions depend on them, for example:
1. If your function is defined as auth triggers, you should assign this function to auth stack.
2. If your function is used as data resolver or calls data API, you should assign this function to data stack.
To assign a function to a different stack, use the property 'resourceGroupName' in the defineFunction call and choose auth, data or any custom stack.

If your circular dependency issue is not resolved with this workaround, please create an issue here https://github.com/aws-amplify/amplify-backend/issues/new/choose

このエラーは、例えば「ストレージ(S3)が関数Aを参照し、関数Aが関数Bを参照し、関数Bがストレージ(S3)を参照する」といった依存関係のループが発生した場合に起こります。

エラーが発生するシナリオ例

以下のようなディレクトリ構造とコードを例に、エラーが発生するシナリオを見ていきましょう。

ディレクトリ構造
amplify/
├── backend.ts
├── package.json
├── tsconfig.json
├── data/
│   └── resource.ts
└── storage/
    ├── resource.ts
    └── functions/
        ├── convert-file/
        │   ├── handler.ts
        │   └── resource.ts
        └── on-upload-handler/
            ├── handler.ts
            └── resource.ts

S3 トリガー関数の定義 (onUploadHandler)

S3 バケットへのファイルアップロードをトリガーとして実行される Lambda 関数 onUploadHandler を定義します。この関数は、別の Lambda 関数 convertFile を呼び出します。

amplify/storage/functions/on-upload-handler/resource.ts

import { defineFunction } from '@aws-amplify/backend'

export const onUploadHandler = defineFunction({
  environment: {
    // convertFile 関数の ARN を環境変数として受け取る
    GET_FILE_NAME_FUNCTION_ARN: process.env.GET_FILE_NAME_FUNCTION_ARN ?? '',
  },
  resourceGroupName: 'storage', // <- storage リソースグループに配置
})

amplify/storage/functions/on-upload-handler/handler.ts

import type { Handler } from 'aws-lambda'
import { env } from '$amplify/env/on-upload-handler'
import { LambdaClient, InvokeCommand, InvocationType } from '@aws-sdk/client-lambda'

const lambdaClient = new LambdaClient()

export const handler: Handler = async (event) => {
  if (!env.GET_FILE_NAME_FUNCTION_ARN) {
    console.error('GET_FILE_NAME_FUNCTION_ARN is not defined in environment variables.')
    return {
      statusCode: 500,
      body: JSON.stringify('GET_FILE_NAME_FUNCTION_ARN is not defined.'),
    }
  }

  try {
    // convertFile 関数を非同期で呼び出す
    const response = await lambdaClient.send(new InvokeCommand({
      FunctionName: env.GET_FILE_NAME_FUNCTION_ARN,
      InvocationType: InvocationType.Event,
      Payload: JSON.stringify({ message: 'Hello from on-upload-handler' }),
    }))
    console.log('Lambda invocation successful:', response)
  } catch (error) {
    console.error('Error invoking Lambda function:', error)
    return {
      statusCode: 500,
      body: JSON.stringify('Error invoking Lambda function.'),
    }
  }
}

ファイル変換関数の定義 (convertFile)

onUploadHandler から呼び出される convertFile 関数を定義します。この関数は、S3 バケットの情報(ARN)を環境変数として利用します。

amplify/storage/functions/convert-file/resource.ts

import { defineFunction } from '@aws-amplify/backend'

export const convertFile = defineFunction({
  environment: {
    // S3 バケットの ARN を環境変数として受け取る
    BUCKET_ARN: process.env.BUCKET_ARN ?? '',
    // TODO_TABLE_ARN: process.env.TODO_TABLE_ARN ?? '', // AppSync連携時に使用
  },
  resourceGroupName: 'storage', // <- storage リソースグループに配置
})

amplify/storage/functions/convert-file/handler.ts

import type { Handler } from 'aws-lambda'
import { env } from '$amplify/env/convert-file' // '$amplify/env/get-file-name' から修正

export const handler: Handler = async (event) => {
  console.log('event', event)
  console.log('BUCKET_ARN', env.BUCKET_ARN)
  // console.log('TODO_TABLE_ARN', env.TODO_TABLE_ARN) // AppSync連携時に使用
}

S3 バケットの定義とトリガー設定

S3 バケットを定義し、onUpload トリガーとして onUploadHandler を設定します。

amplify/storage/resource.ts

import { defineStorage } from '@aws-amplify/backend';
import { onUploadHandler } from './functions/on-upload-handler/resource';

export const storage = defineStorage({
  name: 'storage', // S3 バケット名
  triggers: {
    onUpload: onUploadHandler, // アップロード時に onUploadHandler を実行
  },
});

バックエンド定義と環境変数設定 (backend.ts)

backend.ts で、各リソースを統合し、関数に必要な環境変数(他リソースの ARN など)を設定します。

amplify/backend.ts

import { defineBackend } from '@aws-amplify/backend';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';

import { storage } from './storage/resource';
import { onUploadHandler } from './storage/functions/on-upload-handler/resource';
import { convertFile } from './storage/functions/convert-file/resource';
// import { data } from './data/resource'; // AppSync連携時に使用

const backend = defineBackend({
  storage,
  onUploadHandler,
  convertFile,
  // data, // AppSync連携時に使用
});

// S3 バケットの ARN を取得
const bucketArn = backend.storage.resources.bucket.bucketArn;
// convertFile 関数の ARN を取得
const convertFileFunctionArn = backend.convertFile.resources.lambda.functionArn;

// onUploadHandler に convertFile の ARN を環境変数として設定
backend.onUploadHandler.addEnvironment('GET_FILE_NAME_FUNCTION_ARN', convertFileFunctionArn);
// onUploadHandler に convertFile を呼び出す権限を付与
backend.onUploadHandler.resources.lambda.addToRolePolicy(new PolicyStatement({
  actions: ['lambda:InvokeFunction'],
  resources: [convertFileFunctionArn],
}));

// convertFile に S3 バケットの ARN を環境変数として設定
backend.convertFile.addEnvironment('BUCKET_ARN', bucketArn);

// --- AppSync 連携時の設定 ---
// const todoTableArn = backend.data.resources.tables.Todo.tableArn;
// backend.convertFile.addEnvironment('TODO_TABLE_ARN', todoTableArn);
// ---------------------------

なぜ循環参照エラーが発生するのか? (resourceGroupName 未指定の場合)

上記の構成で、onUploadHandlerconvertFileresource.ts において resourceGroupName を指定しない場合、Amplify はデフォルトで各関数を個別の CloudFormation ネストスタック(リソースグループ)に配置しようとします。

  1. storage -> function (onUploadHandler):
    storage/resource.tstriggersonUploadHandler を指定しているため、storage リソースグループは onUploadHandler 関数が属するリソースグループに依存します。
  2. function (onUploadHandler) -> function (convertFile):
    backend.tsonUploadHandler の環境変数に convertFile の ARN を設定しているため、onUploadHandler リソースグループは convertFile 関数が属するリソースグループに依存します。
  3. function (convertFile) -> storage:
    backend.tsconvertFile の環境変数に storage (S3 バケット) の ARN を設定しているため、convertFile リソースグループは storage リソースグループに依存します。

これにより、 storage -> onUploadHandler -> convertFile -> storage という依存関係のループが発生し、循環参照エラーとなります。

解決策: resourceGroupName の指定

この問題を解決するには、関連する関数を同じリソースグループにまとめることが有効です。今回の例では、S3 に関連する onUploadHandlerconvertFilestorage リソースグループに配置します。

amplify/storage/functions/on-upload-handler/resource.ts

import { defineFunction } from '@aws-amplify/backend'

export const onUploadHandler = defineFunction({
  environment: {
    GET_FILE_NAME_FUNCTION_ARN: process.env.GET_FILE_NAME_FUNCTION_ARN ?? '',
  },
  resourceGroupName: 'storage', // <- storage リソースグループに配置
})

amplify/storage/functions/convert-file/resource.ts

import { defineFunction } from '@aws-amplify/backend'

export const convertFile = defineFunction({
  environment: {
    BUCKET_ARN: process.env.BUCKET_ARN ?? '',
    // TODO_TABLE_ARN: process.env.TODO_TABLE_ARN ?? '',
  },
  resourceGroupName: 'storage', // <- storage リソースグループに配置
})

このように resourceGroupName: 'storage' を指定することで、onUploadHandlerconvertFilestorage リソースグループ(S3 バケットと同じネストスタック)内にデプロイされます。これにより、リソースグループ間の循環依存がなくなり、エラーが解消されます。

AppSync との連携に関する注意点

もし convertFile 関数を AppSync のミューテーションリゾルバーとしても使用したい場合、状況は少し複雑になります。

amplify/data/resource.ts

import { type ClientSchema, a, defineData } from '@aws-amplify/backend';
import { convertFile } from '../storage/functions/convert-file/resource'; // storage グループの関数を参照

const schema = a.schema({
  Todo: a
    .model({
      content: a.string(),
    })
    .authorization((allow) => [allow.guest()]),
  // convertFile 関数をミューテーションとして公開
  convertFile: a
    .mutation()
    .returns(a.string())
    .handler(a.handler.function(convertFile)) // ここで convertFile を参照
    .authorization((allow) => [allow.guest()]),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: 'iam',
  },
});

backend.tsdata リソースを有効化します。

amplify/backend.ts

// ... (imports)
import { data } from './data/resource';

const backend = defineBackend({
  storage,
  onUploadHandler,
  convertFile,
  data, // data リソースを追加
});

// ... (environment variable settings)

この時点では、data リソースグループが storage リソースグループ(convertFile が属する)を参照するだけなので、まだ循環参照は発生しません (data -> storage)。

しかし、もし convertFile 関数が DynamoDB テーブル(data リソースグループに属する)にアクセスする必要があり、backend.ts で以下のようにテーブルの ARN を環境変数として convertFile に渡すと、再び循環参照が発生します。

amplify/backend.ts (問題が発生する設定)

// ...
const todoTableArn = backend.data.resources.tables.Todo.tableArn; // data グループのリソース
// convertFile (storage グループ) に data グループの ARN を渡す
backend.convertFile.addEnvironment('TODO_TABLE_ARN', todoTableArn);
// ...

この設定により、storage リソースグループ (convertFile) が data リソースグループ (Todo テーブル) を参照することになります (storage -> data)。
すでに data -> storage の依存関係が存在するため、ここで storage <=> data の循環参照が発生してしまいます。

この場合、convertFilestorage グループに置いても data グループに置いても循環参照を避けられません。解決策としては、以下のようなアプローチが考えられます。

  • 関数を分割する: AppSync 用の処理と S3 トリガー用の処理を別の関数に分け、それぞれ適切なリソースグループに配置する。
  • 依存関係を逆転させる: 例えば、AppSync 側の関数から S3 トリガー関数を呼び出すのではなく、別の方法(EventBridge など)で連携する。
  • カスタムスタックを利用する: 依存関係を整理するために、より細かくカスタムリソースグループ(スタック)を定義する。

まとめ

Amplify Gen2 における循環参照エラーは、リソース間の依存関係、特に複数のリソースグループをまたがる場合に発生しやすくなります。エラーメッセージを確認し、どのリソース間で循環が発生しているかを把握することが重要です。

多くの場合、defineFunctionresourceGroupName プロパティを使用して、関連する関数を依存先の主要なリソースグループ(例: storage, data, auth)に配置することで、循環参照を解消できます。

AppSync との連携など、より複雑な依存関係を持つ場合は、関数の責務分割やカスタムスタックの利用も検討しましょう。

リバナレテックブログ

Discussion