CDKでスタック間参照してはならない

はじめに

皆様こんにちは、ソーシャル経済メディア「NewsPicks」(Media Infrastructureチーム)エンジニアの北見です。

AWS における有力な IaaC の選択肢として、CDK が挙げられます。

tech.uzabase.com

今回は、私が CDK におけるバッドプラクティスだと思っている「スタック間参照」について説明させて頂きます。

既に CDK を運用している方にとっては釈迦に説法ですが、お付き合い頂けると嬉しいです。

スタック間参照すると何が問題になるか?

スタック間参照とは、以下の様にとあるスタックのリソースを別のスタックで参照することを指します。

例えば、デプロイ用のイメージがpushされる ECR を管理する StorageStackと、アプリケーションを動作させる基盤を管理する ApplicationStack に分けたとして、両者をスタック間参照するコードを見てみましょう。

export class StorageStack : extends cdk.Stack {
    readonly applicationRepository: ecr.Repository;

    constructor(scope: Construct, id: string){
         super(scope, id);
         this.applicationRepository = new ecr.Repository(this, "applicationEcr", {
            repositoryName: "application-ecr",
            removalPolicy: props.accountProps.storageRemovalPolicy,
            imageScanOnPush: true,
        });
    }
}
export class ApplicationStack : extends cdk.Stack {
    constructor(scope: Construct, id: string, applicationEcr: ecr.Repository){
         super(scope, id);

         const taskDefinition = new ecs.FargateTaskDefinition(...)
         const applicationContainer = taskDefinition.addContainer(
                 "applicationContainer", 
                 {
                        image: ecs.ContainerImage.fromEcrRepository(applicationEcr),
                        ...
                 }
         );
    }
const storageStack = new StorageStack(app, "StorageStack");
const applicationStack = new ApplicationStack(app, "ApplicationStack", storageStack.applicationRepository);

一見問題なさそうに思えるこのコードは、applicationRepository リソースを削除したり名前を変更した場合に問題を引き起こします。

         this.applicationRepository = new ecr.Repository(this, "applicationEcr", {
             // 変更後
            repositoryName: "application-ecr-new",
            removalPolicy: props.accountProps.storageRemovalPolicy,
            imageScanOnPush: true,
        });
cdk deploy
23:41:12 | UPDATE_ROLLBACK_IN_P | AWS::CloudFormation::Stack | StorageStack
Export StorageStack:ExportsOutputRefapplicationEcrXXXXXXXXXXXXXXXX cannot be deleted as it is in use by ApplicationStack

なんと cdk deploy できません。

CDK のコード上では依存関係は無くなっているのですが、CDK が自動で作っている Output が削除されることとなり、 cdk deploy に失敗してしまいます。

スタック間参照を行わないリソースの参照方法

では、そもそもどのようにスタック間参照を行わないようにリソースを管理・参照すれば良かったのでしょうか?

解決策1. 名前・IDを使ってリソースを参照する

CDK にはリソースを参照するために、様々な関数が用意されています。

たとえば ECR の場合、ecr.Repository.fromRepositoryName を使えば

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

         const taskDefinition = new ecs.FargateTaskDefinition(...)  
         // fromRepositoryNameによる名前を使ったリソース参照
         const applicationEcr = ecr.Repository.fromRepositoryName(this, "applicationEcr", "application-ecr")
         const applicationContainer = taskDefinition.addContainer(
                 "applicationContainer", 
                 {
                        image: ecs.ContainerImage.fromEcrRepository(applicationEcr),
                        ...
                 }
         );
    }

のように、名前によるリソース参照が可能です。

この方法を使えば、スタック間参照による cdk deploy の問題を引き起こしません。

もちろん、

  1. applicationRepository をシンボルセーフに参照することはできない

  2. 命名ベースの参照になるので、ルール化による認知コストがかかる

というデメリットはあるのですが、cdk deploy できなくなるよりはマシです。

解決策2. そもそもスタックを分割しない

「そもそも不必要にスタックを分割しない」というのも優れた解決方法の1つです。

const storageStack = new StorageStack(app, "Storage");
const applicationStack = new ApplicationStack(app, "Application", storageStack .applicationRepository);

例としてコードを示しておきながらですが、ECR について、そもそもこの両者は分割する必要があるのでしょうか?

デプロイ環境ごとに対応する ECR を置くことを前提とすれば、ECR の管理は ApplicationStack で行ってしまって良さそうです。

勿論 StorageStack に対して ApplicationStack が1対多の場合は分割した方が良いのですが、まずは「本当にスタック分割が必要になるか?」と問いかけることで「実は不要に分割してた」というパターンを極力減らすことができます。

もし現状でスタック間参照をしてしまっている場合の回避方法

とはいえスタック間参照の副作用に気づけず、現状の CDK のコードがスタック間参照してしまっているケースもあるでしょう。

ここまででご紹介しているコードを例に、スタック間参照を引きはがす方法を説明します。

export class StorageStack : extends cdk.Stack {
    readonly applicationRepository: ecr.Repository;

    constructor(scope: Construct, id: string){
         super(scope, id);
         this.applicationRepository = new ecr.Repository(this, "applicationEcr", {
            repositoryName: "application-ecr",
            removalPolicy: props.accountProps.storageRemovalPolicy,
            imageScanOnPush: true,
        });
    }
}
export class ApplicationStack : extends cdk.Stack {
    constructor(scope: Construct, id: string, applicationEcr: ecr.Repository){
         super(scope, id);

         const taskDefinition = new ecs.FargateTaskDefinition(...)
         const applicationContainer = taskDefinition.addContainer(
                 "applicationContainer", 
                 {
                        image: ecs.ContainerImage.fromEcrRepository(applicationEcr),
                        ...
                 }
         );
    }
const storageStack = new StorageStack(app, "Storage");
const applicationStack = new ApplicationStack(app, "Application", storageStack.applicationRepository);

1. StorageStack 側で exportValue を使い、コード変更してもスタック間の依存関係に変更がないかのように CDK を騙す

export class StorageStack : extends cdk.Stack {
    readonly applicationRepository: ecr.Repository;

    constructor(scope: Construct, id: string){
         super(scope, id);
         this.applicationRepository = new ecr.Repository(this, "applicationEcr", {
            repositoryName: "application-ecr",
            removalPolicy: props.accountProps.storageRemovalPolicy,
            imageScanOnPush: true,
        });

        // 変更箇所。
        // ECRではrepositoryArnとrepositoryNameを出力すればエラー回避できますが、リソースの種類によってexportValueする必要のある値は異なります。
        this.exportValue(this.applicationRepository.repositoryArn);
        this.exportValue(this.applicationRepositoryrepositoryName);
    }
}

これを追加することによって、StorageStackapplicationRepository の名前やIDを変更しても、Output から削除されなくなる...と CDK に認識させることができます。

2.リソースの参照を名前・ID解決する関数による方法で置き換える

次に ApplicationStack 内部で利用している applicationEcr を名前解決する方法で取得するようにします。

export class ApplicationStack : extends cdk.Stack {
    // 変更箇所。StorageStackからECRを受け取らないように変更
    constructor(scope: Construct, id: string){
         super(scope, id);

         const taskDefinition = new ecs.FargateTaskDefinition(...)
         
         // 変更箇所。ecr.Repository.fromRepositoryNameを利用した、名前によるリソース解決
         // ECRに限らず、CDKにはリソースを名前によって解決する便利な関数が多数実装されている
         const applicationEcr = ecr.Repository.fromRepositoryName(this, "applicationEcr", "application-ecr")
         const applicationContainer = taskDefinition.addContainer(
                 "applicationContainer", 
                 {
                        image: ecs.ContainerImage.fromEcrRepository(applicationEcr),
                        ...
                 }
         );
    }
// 変更箇所。スタック間参照がなくなっている
const storageStack = new StorageStack(app, "Storage");
const applicationStack = new ApplicationStack(app, "Application");

3. cdk deploy する

この段階で一度 cdk deploy する必要があります。

4. exportValue を削除し、再度 cdk deploy する

export class StorageStack : extends cdk.Stack {
    readonly applicationRepository: ecr.Repository;

    constructor(scope: Construct, id: string){
         super(scope, id);
         this.applicationRepository = new ecr.Repository(this, "applicationEcr", {
            repositoryName: "application-ecr",
            removalPolicy: props.accountProps.storageRemovalPolicy,
            imageScanOnPush: true,
        });
    }
}
export class ApplicationStack : extends cdk.Stack {
    constructor(scope: Construct, id: string){
         super(scope, id);

         const taskDefinition = new ecs.FargateTaskDefinition(...)  
         const applicationEcr = ecr.Repository.fromRepositoryName(this, "applicationEcr", "application-ecr")
         const applicationContainer = taskDefinition.addContainer(
                 "applicationContainer", 
                 {
                        image: ecs.ContainerImage.fromEcrRepository(applicationEcr),
                        ...
                 }
         );
    }

このコードを cdk deploy すれば、スタック間参照がなくなった CDK のコードが AWS 上に反映されます。

注意しなければいけないのは、StorageStack で宣言している ECR の名前が変更されたとき、ApplicationStackecr.Repository.fromRepositoryName も変更が必要という点です。

これはどうしても必要なので、諦めましょう。

おわりに

実はスタック間参照していても、問題なく運用出来ているケースがあるのかも知れません。

ですが、 cdk deploy を失敗させる原因をわざわざ作るリスクを上回るメリットは現状ないと思っています。

回避策として exportValue を紹介しましたが、CDK を騙すのはそもそも本質的に価値ある作業ではないので、このようなワークアラウンドがないのが理想です。

こういった諸々の事情を考えると、例え拡張の予定がなかったとしても

「CDKでスタック間参照してはならない」

という合言葉が、シンプルで運用が楽になるルールなのではないでしょうか?

CDK の運用経験が既にある方にとっては釈迦に説法な内容でしたが、「しない方が良いですよ」とまで踏み込んでいる記事はあまり見かけず、注意喚起のニュアンスも込めてお話させて頂きました。

ここまで読んで下さり、ありがとうございました。

Page top