急にCDKのスタックが変更できなくなった


スタックが二つあり一方が他方をクロススタック参照しているような CDK アプリケーションをメンテナンスしていたら、急に変更を加えていないスタックがデプロイできなくなりました。 オチとしては、スタックに変更を加えていないと思い込んでいるだけで、実際にはスタックに変更を加えていました。

クラススタック参照するアプリケーション

例えば、以下のようなアプリケーションを考えます。

class StackA extends cdk.Stack { 
  public readonly bucket: s3.Bucket 
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { 
    super(scope, id, props); this.bucket = new s3.Bucket(this, 'Bucket');
  } 
} 
class StackB extends cdk.Stack { 
  constructor(scope: cdk.App, id: string, props: cdk.StackProps & { bucket: s3.Bucket }) { 
    super(scope, id, props);
    const { bucket } = props;
    const role = new iam.Role(this, 'Role', { assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), })
    bucket.grantRead(role); 
  } 
} 
const flag = false;
const app = new cdk.App();
const stackA = new StackA(app, 'A');
if (flag) { 
  new StackB(app, 'B', { bucket: stackA.bucket });
} 

StackBStackA を参照しています。この例であれば、実際には s3.Bucketiam.Role を同じスタックに記述すると思いますが、今回は現象を再現するためにクラススタック参照を使っています。 ## デプロイしたりしなかったりするスタック 現象を再現する上でポイントになってくるのは、flag に関する部分です。flag は何らかの条件によって StackB が不要になることを表しています。

const flag = false; 
// ... 
if (flag) { new StackB(app, 'B', { bucket: stackA.bucket }); } 

まずは flag: false の状態でデプロイします。

> cdk diff
...
Resources [+] AWS::S3::Bucket Bucket Bucket83908E77
...
> cdk deploy 
A | 3/3 | 13:17:19 | CREATE_COMPLETE | AWS::CloudFormation::Stack | A 

想定とおり StackA だけがデプロイされます。次に flag: true の状態でデプロイします。

> cdk diff 
Stack A Outputs [+] Output Exports/Output{"Fn::GetAtt":[ ... Stack B ... Resources [+] AWS::IAM::Role Role [+] AWS::IAM::Policy Role/DefaultPolicy 
> cdk deploy --all
...
A | 2/2 | 15:00:40 | UPDATE_COMPLETE | AWS::CloudFormation::Stack | A 
... 
B | 4/4 | 15:02:02 | CREATE_COMPLETE | AWS::CloudFormation::Stack | B 
...

StackA のコードを編集していないので、StackB だけがデプロイされて、StackA は変更されないと思い込んでいました。実際には StackBStackA を参照できるように、CDK は StackAStackB よりも前にデプロイして、さらに StackAExport を追加しました。

次に flag: false の状態でデプロイを試みます。StackB が削除されると思っていましたが、これも勘違いでした。

> cdk diff Stack A Outputs [-] Output ExportsOutput
... 
> cdk deploy -all 
...
A Export A:ExportsOutput
... 
cannot be deleted as it is in use by B 

実際には StackAExport を削除しようとして失敗します。StackB は変更されません。したがって、StackB がまだ参照しているので、Export を削除できずに失敗します。このとき、StackA のコードを変更していないのに、StackA のデプロイが急に失敗するようになったと感じました。

条件次第でデプロイするリソース

条件によって StackB をインスタンス化するのではなく、StackB のリソースをインスタンス化するように書き直しました。

const flag = false;
// ... 
class StackB extends cdk.Stack { 
  constructor(scope: cdk.App, id: string, props: cdk.StackProps & { flag: boolean, bucket: s3.Bucket }) { 
    super(scope, id, props);
    const { flag, bucket } = props;
    if (!flag) { return; } 
// ... 
  } 
} 
// ... 
const stackA = new StackA(app, 'A');
new StackB(app, 'B', { flag, bucket: stackA.bucket }); 

flag: true のときは最初のコードと等価ですが、flag: false のときは等価ではありません。最初のコードでは StackB は存在しないことになりますが、このコードでは何もリソースがないスタックを表しています。 flag: false の状態でデプロイします。

> cdk diff 
Stack A Outputs [-] Output ExportsOutput
... 
Resources [-] AWS::IAM::Role Role Role1ABCC5F0 destroy [-] AWS::IAM::Policy Role/DefaultPolicy RoleDefaultPolicy5FFB7DAB destroy 
> cdk deploy -all 
...
A Export A:ExportsOutput
... 
cannot be deleted as it is in use by B 

CDK はトポロジカル順序にクロススタック参照を作成するようですが、クロススタック参照を削除するときは賢明でないようです。先に StackA を変更しようとするので、依然として StackBStackA を参照していると考え、Export を削除できずに失敗します。

リソースを削除する順序を考慮する

CDK のデプロイ順序は仕様として決まっているのか調べていないですが、インスタンス化する順序に依存していそうでした。最終的に以下のように書き直すことで、flag を自由に入れ替えることができます。

type StackBProps = ({ 
  flag: true 
  bucket: s3.Bucket
} | { 
  flag: false 
  bucket?: undefined 
}) & cdk.StackProps 

class StackB extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props: StackBProps) { 
    super(scope, id, props); 
    const { flag, bucket } = props; 
    if (!flag) { return; } 
    const role = new iam.Role(this, 'Role', { 
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), 
    });
    bucket.grantRead(role);
  } 
} 

const flag = false;
const app = new cdk.App();
if (flag) { const stackA = new StackA(app, 'A');
  new StackB(app, 'B', { flag, bucket: stackA.bucket });
} else { 
  // To remove the reference to StackA, StackB must be deployed before StackA.
  new StackB(app, 'B', { flag }); new StackA(app, 'A'); 
} 

依存関係が少ないうちはいいのですが、複雑になると正しい順序が分からなくなり得ます。クロススタック参照するスタックを削除するときは、クロススタック参照されるスタックより先にデプロイしなければいけません。 ## 教訓 - クロススタック参照があったりなかったりするアプリケーションは望ましくない。 - スタック内でリソースの有無を条件分岐する方が素直だと思いました。 - 自分の想定していない変更がないか、cdk diff をよく確認するとよい。 - クロススタック参照する場合は、そのスタックが削除できるか確認するとよい。 - そのために CDK の単体テストを導入してもいいかもしれません。