はじめに

AWS Cloud Development Kit (CDK)を使用してインフラストラクチャをコード化する際、複数のスタック間でリソースを参照する必要が生じることがよくあります。このクロススタック参照を実現する方法として、Fn::ImportValue(CDKではimportValue)がよく使われますが、これが思わぬ循環参照を引き起こすケースがあります。本記事では、その問題点と、ARN参照を使った解決策について解説します。

importValueによる循環参照の罠

問題の概要

importValueは、あるスタックから別のスタックへ値を渡すための基本的な方法ですが、以下のような状況で循環参照が発生します:

  1. スタックAがリソースを作成し、その属性をexportValueでエクスポート
  2. スタックBがその値をimportValueでインポートして使用
  3. スタックBが別のリソースを作成し、その属性をエクスポート
  4. スタックAがスタックBからエクスポートされた値をインポート

この状況では、CloudFormationのデプロイメントが失敗し、以下のようなエラーが発生します:

Error: Circular dependency between resources: [StackA, StackB]

具体例

以下に、Lambda関数とLambdaレイヤーを使用した循環参照が発生する具体例を示します:

// layer-stack.ts
export class LayerStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 共通ライブラリのLambdaレイヤーを作成
    const commonLayer = new lambda.LayerVersion(this, 'CommonLibLayer', {
      code: lambda.Code.fromAsset('layers/common-lib'),
      compatibleRuntimes: [lambda.Runtime.NODEJS_18_X],
      description: '共通ユーティリティライブラリ',
    });

    // レイヤーのARNをエクスポート
    new cdk.CfnOutput(this, 'CommonLayerArnOutput', {
      value: commonLayer.layerVersionArn,
      exportName: 'CommonLayerArn'
    });

    // 監視用Lambda関数のARNをインポート(循環参照の原因)
    const monitoringFunctionArn = Fn.importValue('MonitoringLambdaArn');

    // 監視Lambda関数にレイヤーの更新を通知するためのリソースベースポリシー
    // これが循環参照を引き起こす
    const layerPolicy = new iam.PolicyStatement({
      actions: ['lambda:InvokeFunction'],
      resources: [monitoringFunctionArn],
      effect: iam.Effect.ALLOW,
    });

    // ポリシーをレイヤー管理用のロールにアタッチ
    // ...
  }
}

// lambda-stack.ts
export class LambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 共通レイヤーのARNをインポート
    const commonLayerArn = Fn.importValue('CommonLayerArn');
    const commonLayer = lambda.LayerVersion.fromLayerVersionArn(
      this, 
      'ImportedCommonLayer', 
      commonLayerArn
    );

    // 監視用Lambda関数を作成
    const monitoringFunction = new lambda.Function(this, 'MonitoringFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/monitoring'),
      layers: [commonLayer],
      environment: {
        LAYER_ARN: commonLayerArn
      }
    });

    // 監視Lambda関数のARNをエクスポート
    new cdk.CfnOutput(this, 'MonitoringLambdaArnOutput', {
      value: monitoringFunction.functionArn,
      exportName: 'MonitoringLambdaArn'
    });
  }
}

この例では、LayerStackがLambdaレイヤーを作成してそのARNをエクスポートし、LambdaStackがそのARNをインポートして監視用Lambda関数を作成します。さらに、LambdaStackはLambda関数のARNをエクスポートし、LayerStackがそれをインポートしようとしています。これにより循環参照が発生します。

ARN参照による解決策

基本的なアプローチ

循環参照を避けるためには、importValueの代わりに、リソースのARN(Amazon Resource Name)を直接参照する方法が効果的です。ARN参照では、リソースの物理IDやARNを直接使用するため、CloudFormationのエクスポート/インポートメカニズムを回避できます。

改善された実装例

// layer-stack.ts
export class LayerStack extends cdk.Stack {
  public readonly commonLayer: lambda.LayerVersion;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 共通ライブラリのLambdaレイヤーを作成
    this.commonLayer = new lambda.LayerVersion(this, 'CommonLibLayer', {
      code: lambda.Code.fromAsset('layers/common-lib'),
      compatibleRuntimes: [lambda.Runtime.NODEJS_18_X],
      description: '共通ユーティリティライブラリ',
    });

    // レイヤーARNの出力(参考用)
    new cdk.CfnOutput(this, 'CommonLayerArn', {
      value: this.commonLayer.layerVersionArn,
      description: '共通ライブラリレイヤーのARN'
    });
  }
}

// lambda-stack.ts
export interface LambdaStackProps extends cdk.StackProps {
  commonLayer: lambda.LayerVersion;
}

export class LambdaStack extends cdk.Stack {
  public readonly monitoringFunction: lambda.Function;

  constructor(scope: Construct, id: string, props: LambdaStackProps) {
    super(scope, id, props);

    const { commonLayer } = props;

    // 監視用Lambda関数を作成
    this.monitoringFunction = new lambda.Function(this, 'MonitoringFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/monitoring'),
      layers: [commonLayer],
      environment: {
        LAYER_ARN: commonLayer.layerVersionArn
      }
    });

    // Lambda関数ARNの出力(参考用)
    new cdk.CfnOutput(this, 'MonitoringFunctionArn', {
      value: this.monitoringFunction.functionArn,
      description: '監視用Lambda関数のARN'
    });
  }
}

// notification-stack.ts
export interface NotificationStackProps extends cdk.StackProps {
  commonLayer: lambda.LayerVersion;
  monitoringFunction: lambda.Function;
}

export class NotificationStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: NotificationStackProps) {
    super(scope, id, props);

    const { commonLayer, monitoringFunction } = props;

    // レイヤー更新通知用のリソースを作成
    const layerUpdateNotification = new lambda.Function(this, 'LayerUpdateNotification', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda/layer-notification'),
      environment: {
        MONITORING_FUNCTION_ARN: monitoringFunction.functionArn,
        LAYER_ARN: commonLayer.layerVersionArn
      }
    });

    // 監視Lambda関数を呼び出す権限を付与
    monitoringFunction.grantInvoke(layerUpdateNotification);

    // 通知Lambda関数ARNの出力(参考用)
    new cdk.CfnOutput(this, 'NotificationFunctionArn', {
      value: layerUpdateNotification.functionArn,
      description: 'レイヤー更新通知用Lambda関数のARN'
    });
  }
}

// app.ts
const app = new cdk.App();

// レイヤースタックの作成
const layerStack = new LayerStack(app, 'LayerStack');

// Lambda関数スタックの作成(レイヤーを直接参照)
const lambdaStack = new LambdaStack(app, 'LambdaStack', {
  commonLayer: layerStack.commonLayer
});

// 通知スタックの作成(レイヤーとLambda関数を直接参照)
new NotificationStack(app, 'NotificationStack', {
  commonLayer: layerStack.commonLayer,
  monitoringFunction: lambdaStack.monitoringFunction
});

この改善された実装では、スタック間でリソースを直接参照するために、コンストラクタのプロパティとしてリソースを渡しています。これにより、CloudFormationのエクスポート/インポートメカニズムを使用せず、循環参照の問題を回避できます。また、依存関係を明確にするために、通知機能を別のスタックに分離しています。

まとめ

CDKでクロススタック参照を行う際には、以下のポイントを覚えておきましょう:

  1. importValueは便利ですが、複雑な依存関係がある場合は循環参照を引き起こす可能性がある
  2. 循環参照を避けるためには、リソースのARNや物理IDを直接参照する方法が効果的
  3. スタック間でリソースを共有する場合は、コンストラクタのプロパティとして渡すパターンを検討する
  4. 依存関係を明確に設計し、一方向の依存関係を維持することが重要

ARN参照を使用したアプローチは、より柔軟で堅牢なインフラストラクチャコードを作成するのに役立ちます。特に大規模なプロジェクトや、複雑な依存関係を持つアーキテクチャでは、この方法を検討することをお勧めします。

参考リソース