atama plus techblog
🐡

AWS CDKのクロススタック参照と仲良くなりたい

に公開

こんにちは! atama plusのnomu3です。

現在、弊社ではインフラのセルフサービス化を推進しており、私たちSREチームでは開発者がCDKを利用してインフラ構築するにあたってのガイドライン整備や独自ライブラリによるサポートなどを行なっています。

そんな中で先日、新たにスタック間でリソース参照する際のノウハウについて整理して社内向けにガイドラインを作成したため、この記事ではそのポイントやクロススタック参照における考慮事項などを紹介します。

背景

(AWS CDKの技術的な前提のお話になるため、知っている人は飛ばしてください)

AWS CDK(CloudFormation)のスタックにおいて、1つのスタックでは1つのリージョンのリソースしか記述できません。
そのためマルチリージョン構成のシステムの場合は勿論のこと、単一リージョン前提のシステムであっても以下の様な理由でスタックが複数になる事が有り得るかと思います。

  • 1スタックのリソース数上限(500個)を超過する場合
  • 特定リージョン(主にus-east-1)にしかリソースを作成出来ないサービスを利用する場合
    • 新しいサービスのため東京リージョンにリリースされていない
    • 東京リージョンで作成したCloudFront distributionにWAFのWeb ACL(us-east-1に作成する必要があるリソース)を紐付けたい

そのため、クロススタック参照の実装方針は社内ガイドラインとして整備する必要がありました。

採用した方法

同一リージョン内のクロススタック参照の場合

この場合話は単純で、スタックのProps経由で参照したい値を渡してやれば問題ありません。

ProducerStack

export class ProducerStack extends Stack {
    // ConsumerStackから参照できるようにプロパティを公開する
    public readonly bucket: s3.IBucket;
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        // 共有したいリソースを作成
        this.bucket = new s3.Bucket(this, 'SharedBucket', {
            // ...(省略)
    });
  }
}

ConsumerStack

// ProducerStackから受け取るプロパティの型を定義
export interface ConsumerStackProps extends StackProps {
    // s3.IBucketインターフェース型で受け取る
    readonly targetBucket: s3.IBucket;
}

export class ConsumerStack extends Stack {
    constructor(scope: Construct, id: string, props: ConsumerStackProps) {
        super(scope, id, props);
        // propsから渡されたS3バケットオブジェクトを使用
        const bucket = props.targetBucket;
        // ... (省略)
    }
}

EntryPoint

const app = new cdk.App();

const producerStack = new ProducerStack(app, 'ProducerStack', {
    env: {
        account: '123456789012',
        region: 'ap-northeast-1',
    }
});

new ConsumerStack(app, 'ConsumerStack', {
    env: {
        account: '123456789012',
        region: 'ap-northeast-1',
    },
    // ProducerStackのpublicプロパティ(bucket)をprops経由で渡す
    targetBucket: producerStack.bucket,
});

異なるリージョン内のクロススタック参照の場合

方法1: crossRegionReferencesを使用する

別々のリージョンに存在するスタック間でリソースの値を参照したい場合、同一リージョン間の時のやり方が使えません。
基本的にはCDK標準で用意されているcrossRegionReferencesを使った方法が候補に挙がるかと思います。

この場合、スタックレベルでcrossRegionReferencesを有効化する事で同一リージョン間のスタック参照と同じ様にPropsから値を渡すことが可能になります。

(以下、us-east-1(ProducerStack)にあるWAFをap-northeast-1(ConsumerStack)にあるCloudFrontから参照する例となります)

ProducerStack

export class ProducerStack extends Stack {
    public readonly webAclArn: string; // クロススタック参照させる値
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        const webAcl = new wafv2.CfnWebACL(this, 'Default', {
        // ... (省略)
        });
        this.webAclArn = webAcl.webAclArn;
    }
}

ConsumerStack

export interface ConsumerStackProps extends StackProps {
    readonly webAclArn: string;
}

export class ConsumerStack extends Stack {
    constructor(scope: Construct, id: string, wafArn: string, props: ConsumerStackProps) {
        super(scope, id, props);
        const distribution = new cloudfront.Distribution(this, 'Distribution', {
            defaultRootObject: "index.html",
            defaultBehavior: {
                origin: origins.S3BucketOrigin.withOriginAccessControl(originBucket),
                cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
            },
            webAclId: props.webAclArn, // クロススタック参照
            // ... (省略)
        });
    }
}

EntryPoint

const app = new cdk.App();

const producerStack = new ProducerStack(app, 'ProducerStack', {
    env: {
        account: '123456789012',
        region: 'us-east-1',
    },
    crossRegionReferences: true, // リージョン間のクロススタック参照を有効化
});

const consumerStack = new ConsumerStack(app, 'ConsumerStack', {
    env: {
        account: '123456789012',
        region: 'ap-northeast-1',
    },
    crossRegionReferences: true, // リージョン間のクロススタック参照を有効化
    webAclArn: producerStack.webAclArn, // クロススタック参照するリソース
});

問題点: 依存関係の解消手順が複雑

crossRegionReferencesを利用したクロススタック参照は前述の通り非常にシンプルな形で実現可能です。しかしこの方法だとデプロイ時に暗黙的にCloudFormationのImport/Exportが作成され、強い参照(システム的に保護された依存関係)を持ちます。

【補足】CloudFormationの「Export/Import」と「強い参照」とは?

CloudFormationではクロススタック参照する場合、「あるスタックが出力した値(Export)を、別のスタックが読み取っている(Import)」という依存関係を厳密に管理しています。
そしてこの状態にある場合、「誰かに参照されている値(Export)は、絶対に削除・変更させない」 という強力な保護機能が働きます。これを指して「強い参照」と表現されています。

(参考)

この状態で一度作成された依存関係を削除(参照されているProducer側のリソースを削除)したい場合の手順は、普通に考えれば以下の様な順序でデプロイすれば良い様に思えます。

  1. ConsumerStack側の参照を削除
  2. ProducerStack側のリソースを削除

しかし単純にこれをデプロイしようとするとエラーとなります。
これは前述の暗黙的に生成されたImport/Exportが存在するため、リソースを直接削除しようとしても依存関係が保護された状態になっているためです。

そのため、エラー無くリソースを削除するためには実際は以下の様な手順を踏む必要があります。

  1. 明示的にProducerStack側のExportを残した上でConsumerStack側の参照を削除する
  2. ProducerStack側のリソースとExportを削除

ProducerStack

  export class ProducerStack extends Stack {
      public readonly webAclArn: string;
      constructor(scope: Construct, id: string, props?: StackProps) {
          super(scope, id, props);
          const webAcl = new wafv2.CfnWebACL(this, 'Default', {
          // ... (省略)
          });
          this.webAclArn = webAcl.webAclArn;
+         // 明示的にExportを残してConsumerStackからの参照を維持しつつリソース定義との結合を切り離す
+         this.exportValue(this.webAclArn); 
      }
  }

ConsumerStack

  export interface ConsumerStackProps extends StackProps {
-     readonly webAclArn: string; // クロススタック参照を削除
  }
  
  export class ConsumerStack extends Stack {
      constructor(scope: Construct, id: string, wafArn: string, props: ConsumerStackProps) {
          super(scope, id, props);
          const distribution = new cloudfront.Distribution(this, 'Distribution', {
              defaultRootObject: "index.html",
              defaultBehavior: {
                  origin: origins.S3BucketOrigin.withOriginAccessControl(originBucket),
                  cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
              // ... (省略)
              },
-             webAclId: props.webAclArn, // クロススタック参照を削除
          });
      }
  }

EntryPoint

  const app = new cdk.App();
  
  const producerStack = new ProducerStack(app, 'ProducerStack', {
      env: {
          account: '123456789012',
          region: 'us-east-1',
      },
      crossRegionReferences: true,
  });
  
  const consumerStack = new ConsumerStack(app, 'ConsumerStack', {
      env: {
          account: '123456789012',
          region: 'ap-northeast-1',
      },
      crossRegionReferences: true,
-     webAclArn: producerStack.webAclArn, // クロススタック参照を削除
  });

(ここまでデプロイしてようやくProducerStack側のリソースが削除可能になります)

方法2: SSM Parameter Storeを使用する

方法1では強い参照があり安全にリソース管理が出来るというメリットがある反面、参照を削除/修正する際の手順が少々煩雑でした。
そこでもう1つの方法として参照させたい値をSSMのパラメータストアに登録し、それを利用する側のスタックから参照するというやり方が考えられます。

ProducerStack

interface ProducerStackProps extends StackProps {
    webAclParameterName: string; // SSMパラメータ名をstringで受け取る
}

export class ProducerStack extends Stack {
    constructor(scope: Construct, id: string, props?: ProducerStackProps) {
        super(scope, id, props);
        const webAcl = new wafv2.CfnWebACL(this, 'Default', {
            // ... (省略)
    })

    // クロススタック参照させる値(WebAclARN)をパラメータストアに保存する
    new ssm.StringParameter(this, 'WebAclArnParameter', {
        parameterName: props.webAclParameterName, // propsからSSMパラメータ名を取得
        stringValue: webAcl.attrArn,
        description: 'WAF WebACL ARN for CloudFront'
    });
}

ConsumerStack

interface ConsumerStackProps extends StackProps {
  webAclParameterArn: string; // SSMパラメータARNをstringで受け取る
}
export class ConsumerStack extends Stack {
  constructor(scope: Construct, id: string, props: ConsumerStackProps) {
    super(scope, id, props);
    // us-east-1のSSMパラメータを取得(社内向けに提供しているL2Constructを使用)
    const webAclArn = ssm_param_l2.CrossRegionStringParameter.fromParameterArn(
        this,
        'ProducerWebAclArn',
        props.webAclParameterArn // propsからSSMパラメータARNを取得
    );

    const distribution = new cloudfront.Distribution(this, 'Distribution', {
            defaultRootObject: "index.html",
            defaultBehavior: {
                origin: origins.S3BucketOrigin.withOriginAccessControl(originBucket),
                cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
            },
            webAclId: webAclArn.stringValue,
            // ... (省略)
        });
    }
}

EntryPoint

const app = new cdk.App();
// リージョン間参照するSSMパラメータ名
const webAclParameterName = "/cdk/cross-region-refs/webacl-arn"
// リージョン間参照するSSMパラメータARN
const webAclParameterArn = `arn:aws:ssm:us-east-1:123456789012:parameter${webAclParameterName}`;

const producerStack = new ProducerStack(app, 'ProducerStack', {
    env: {
        account: '123456789012',
        region: 'us-east-1',
    },
    // リージョン間参照するSSMパラメータ名をprops経由で渡す
    webAclParameterName: webAclParameterName,
});

new ConsumerStack(app, 'ConsumerStack', {
    env: {
        account: '123456789012',
        region: 'ap-northeast-1',
    },
    // リージョン間参照するSSMパラメータArnをprops経由で渡す
    webAclParameterArn: webAclParameterArn,
});

この方法の場合、方法1と違って暗黙的なImport/Exportによる依存関係は存在せず、パラメータストアによる疎結合な管理が可能になります。
(一度作成されたリソース参照を削除したい場合、ConsumerStackの参照およびパラメータの削除→ProducerStackのリソース削除という直感的な手順で実施出来る)

ただしこれらのメリットは疎結合であるが故に、依存関係が確実に保証されていることとのトレードオフとなります。
そのため方法1と方法2のどちらを使用するかは開発者が状況に応じて選べる様にしました。

また別リージョンのパラメータストアを参照するのはCDKの標準機能では難しいため、社内向けに独自L2 Construct(CrossRegionStringParameter)を提供しています。
このL2 Constructの中では、AWS APIを叩いて直接パラメータストアの値を取得するカスタムリソースが定義されています。
(このカスタムリソースの仕組みはクラスメソッドさんの記事を参考にさせていただきました)

まとめ

異なるリージョン間でのクロススタック参照は一見簡単に見えて、運用含めて意外と考える事が多かったです。

ご紹介したcrossRegionReferences方式とSSMパラメータストア方式にはどちらも一長一短があります。
両方をサポートして開発者が状況に応じてやり方を選択出来る様にしましたが、ここら辺は構築実績や開発者からのフィードバックが蓄積されてきたタイミングで運用を見直ししてみるのも良いかなと思います。

atama plus techblog
atama plus techblog

Discussion