Amplify Gen2 の「Circular dependency between resources: [authXXXXXXXX, dataXXXXXXXX, functionXXXXXXXX]」の発生原因と対処法を調べてみた
いわさです。
Amplify Gen2 を使って AWS リソースの管理を行っていると、ある時次のようなエラーメッセージが発生してデプロイがキャンセルされることがあります。
The CloudFormation deployment has failed.
Caused By: Deployment failed: Error [ValidationError]: Circular dependency between resources: [auth179371D7, data7552DF31, function1351588B]
こちらどういうエラーなのかというと、CloudFormation リソースの循環依存が起きており、デプロイが出来ない状態となっています。
Amplify では Cognito や AppSync、Lambda や S3 など様々なリソースをデプロイします。
Amplify Gen2 では CDK を使って CloudFormation スタックがデプロイされるのですが、例えば次のように Auth、Data、Function を defineBackend で構成するのがベーシックな使い方だと思います。
: const backend = defineBackend({ auth, data, function1, function2, }); : Amplify Gen2 ではネストされたスタックとしてデプロイされ、Auth、Data、Function のベースの親スタックがデプロイされます。Data の中には複数の DynamoDB テーブルがまとまっており、Function の中には複数の Lambda 関数がまとまっています。
コードの実装状態から勝手に依存を解決してスタックを作成してくれます。シンプルな関数を定義した場合であればまず以下のような形になると思います。

Infrastructure Composer での確認例
Data が認可のために Auth に依存していますが、Function はこの時点では特に何にも依存していません。
Auth を Function に依存させる
ここで新しい依存関係を作り出してみます。
Amazon Cognito には認証フローの中で Lambda 関数を実行してカスタム認証処理を行う Lambda トリガーという仕組みがあります。こちらを使ってみましょう。
: const backend = defineBackend({ auth, data, preTokenGenerationV2, }); const { cfnUserPool } = backend.auth.resources.cfnResources cfnUserPool.addPropertyOverride("LambdaConfig.PreTokenGenerationConfig",{ LambdaVersion: 'V2_0', LambdaArn: backend.preTokenGenerationV2.resources.lambda.functionArn, }); : 具体的な実装方法としては以前記事にしたのでそちらを参考にしてください。
上記を実装すると、Auth (Cognito) をデプロイする時点で Function (Lambda) がデプロイ済みである必要があります。そのため、Amplify は自動で次のように依存関係を変更してくれます。便利です。

Functions を Data に依存させる
ここで更に追加の依存を作成します。
DynamoDB Streams から Lambda 関数を実行し、その Lambda 関数から AppSync 経由で別の DynamoDB テーブルに書き込みを行います。
新しく関数を追加し、AppSync のエンドポイントと API キーを環境変数で設定したかったので次のように関数とバックエンドを設定しました。(便宜上、関数は AppSync へアクセスせずに環境変数の取得・出力のみに留めています)
import type { DynamoDBStreamHandler } from "aws-lambda"; import { Logger } from "@aws-lambda-powertools/logger"; const logger = new Logger({ logLevel: "INFO", serviceName: "dynamodb-stream-handler", }); export const handler: DynamoDBStreamHandler = async (event) => { logger.info(`API_URL: ${APPSYNC_API_URL}`); logger.info(`API_KEY: ${APPSYNC_API_KEY}`); }; DynamoDB Streams の実装は以下の公式ドキュメントを参考にしています。
: const backend = defineBackend({ auth, data, preTokenGenerationV2, updateTotalScoreOnActivityFunction, }); backend.updateTotalScoreOnActivityFunction.addEnvironment('API_URL', backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl); backend.updateTotalScoreOnActivityFunction.addEnvironment('API_KEY', backend.data.resources.cfnResources.cfnApiKey?.attrApiKey || ''); const funcitonUpdateTotalScoreOnActivity = backend.updateTotalScoreOnActivityFunction.resources.lambda; const policy = new Policy( Stack.of(funcitonUpdateTotalScoreOnActivity), "TotalScoreFunctionStreamingPolicy", { statements: [ new PolicyStatement({ effect: Effect.ALLOW, actions: [ "dynamodb:DescribeStream", "dynamodb:GetRecords", "dynamodb:GetShardIterator", "dynamodb:ListStreams", ], resources: ["*"], }), ], } ); backend.updateTotalScoreOnActivityFunction.resources.lambda.role?.attachInlinePolicy(policy); const mapping = new EventSourceMapping( Stack.of(funcitonUpdateTotalScoreOnActivity), "TotalScoreFunctionEventSourceMapping", { eventSourceArn: backend.data.resources.tables["ScoreActivities"].tableStreamArn, target: funcitonUpdateTotalScoreOnActivity, batchSize: 100, startingPosition: StartingPosition.LATEST, } ); mapping.node.addDependency(policy); こちらをデプロイしてみると掲題のエラーが発生しました。
: The CloudFormation deployment has failed. Caused By: Deployment failed: Error [ValidationError]: Circular dependency between resources: [auth179371D7, data7552DF31, function1351588B] Resolution: Find more information in the CloudFormation AWS Console for this stack. : 循環依存が発生してしまっているというエラーメッセージです。
.amplify/artifacts/cdk.out/に生成される中間成果物の CloudFormation テンプレートを確認してみます。

このテンプレートから冗長な情報を省いて、依存関係だけわかるように整理したのが以下です。
{ "Description": "hoge", "Resources": { "auth179371D7": { "Type": "AWS::CloudFormation::Stack", "Properties": { "Parameters": { "xxx": { "Fn::GetAtt": [ "function1351588B", "xxx" ] } }, }, "data7552DF31": { "Type": "AWS::CloudFormation::Stack", "Properties": { "Parameters": { "xxx": { "Fn::GetAtt": [ "auth179371D7", "xxx" ] }, } } }, "function1351588B": { "Type": "AWS::CloudFormation::Stack", "Properties": { "Parameters": { "xxx": { "Fn::GetAtt": [ "data7552DF31", "xxx" ] } } } } } } } Auth が Function に依存し、Functions は Data に依存し、Data は Auth に依存していますね。循環してしまっています。
うまいことやって欲しい気もしますが、なぜ、このようなことが起きるのでしょうか。
関数のスタックの中を確認してみます。
{ "Resources": { "pretokengenerationv2lambda3B2A1232": { "Type": "AWS::Lambda::Function" }, "updatetotalscoreonactivitylambdaF545CAD9": { "Type": "AWS::Lambda::Function" }, "TotalScoreFunctionEventSourceMapping847649A9": { "Type": "AWS::Lambda::EventSourceMapping" } } } そう、全ての関数がひとつのスタックの中に入っているので、こういう結果になってしまっています。
pretokengenerationv2 は Auth から参照され、updatetotalscoreonactivitylambda は DynamoDB を参照します。関数ごとに依存関係が異なるのですが、Functions スタックにまとめられてしまうのでこのような挙動になってしまうようです。
ちなみにこちらの事象は、GitHub の Amplify リポジトリで issue として存在しています。
解決方法:関数のスタックを分ける
解決作として、ひとつの Function スタックではなく依存関係が異なる関数ごとにスタックを分けると良さそうです。
ただし、上記 issuer 内でも言及されているのですが、本日時点では defineBackend を使った場合に関数を別スタックに分ける方法は無さそうです。
ではどうするば良いのかというと、Amplify Gen2 のカスタムリソース機能を使ってスタックや関数を通常の CDK リソースとして追加してやれば解決出来そうです。(スマートではない気がするが、仕方ない)
: const backend = defineBackend({ auth, data, preTokenGenerationV2, // updateTotalScoreOnActivityFunction, }); const { cfnUserPool } = backend.auth.resources.cfnResources cfnUserPool.addPropertyOverride("LambdaConfig.PreTokenGenerationConfig",{ LambdaVersion: 'V2_0', LambdaArn: backend.preTokenGenerationV2.resources.lambda.functionArn, }); const updateTotalScoreStack = backend.createStack('UpdateTotalScoreStack'); const funcitonUpdateTotalScoreOnActivity = new NodejsFunction( updateTotalScoreStack, 'update-totalscore-on-activity', { entry: url.fileURLToPath(new URL('./functions/update-totalscore-on-activity/handler.ts', import.meta.url)), environment: { API_URL: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl, API_KEY: backend.data.resources.cfnResources.cfnApiKey?.attrApiKey || '', }, initialPolicy: [ new PolicyStatement({ effect: Effect.ALLOW, actions: [ "dynamodb:DescribeStream", "dynamodb:GetRecords", "dynamodb:GetShardIterator", "dynamodb:ListStreams", ], resources: ["*"], }), ], } ) const mapping = new EventSourceMapping( updateTotalScoreStack, "TotalScoreFunctionEventSourceMapping", { eventSourceArn: backend.data.resources.tables["ScoreActivities"].tableStreamArn, target: funcitonUpdateTotalScoreOnActivity, batchSize: 100, startingPosition: StartingPosition.LATEST, } ); 元の実装から移行したのでポリシーをそのまま書いてしまっているのでそこは改善出来そうですが本記事にはあまり関係ないのでこのままで行きます。
こちらをデプロイしてみると次のように関数スタックを分けることが出来ました。

DynamoDB にアイテムを作成してみます。

Lambda 関数の実行ログを確認してみると、DynamoDB Streams 経由で実行されており、AppSync のエンドポイント、API キーを取得出来ていることが確認出来ました。

さいごに
本日は Amplify Gen2 で「Circular dependency between resources: [authXXXXXXXX, dataXXXXXXXX, functionXXXXXXXX]」が発生した際の対処法を検討してみました。
調べてみたのですが、おそらく本日時点では NodejsFunction を使って別スタックに定義して循環依存を避けるのがワークアラウンドになりそうな感じです。
defineBackend を使いつつ、明示的にスタックを分けたり、暗黙的にうまくスタック分割してくれたりするアップデートが欲しいですね。







