AWS CDK で非 Stage から Stage への移行で置換が発生するリソースとその原因

AWS CDK で非 Stage から Stage への移行で置換が発生するリソースがいくつかあるので、その例や原因をご紹介します。

目次

目次


Stage とは

AWS CDK には Stage という概念があります。これはスタックを抽象化したようなものであり、Stage を使用することで複数のスタックをまとめて管理しやすくなります。

具体的な例に関しては、以下の記事で挙げています。

go-to-k.hatenablog.com


Stage におけるリソースの置換

CDK では、リソースの論理 ID が変更されるとそのリソースは置換されます。

また、非 Stage のリソースを Stage に移行することで、Construct のパスが変更されます。

しかし、論理 ID はスタックから下のコンストラクトパスをもとに生成されるため、Stage に移行しても基本的には論理 ID は変わりません。つまり、ほとんどのケースでリソースの置換は発生しません。


Stage への移行で置換が発生するリソースとその原因

ただし一部のリソースでは、非 Stage から Stage への移行で置換が発生します。

これらのリソースの置換が発生する原因には、以下の 3 つがあります。

  • 論理 ID の変更
  • 物理名の変更
  • Replacement プロパティの変更


また基本的には、非 Stage のリソースを Stage に移行することで変わる情報というのは、Construct のパスに基づく情報です。

例えば、this.node.paththis.node.addr です。他にも、その情報を使用してユニークな ID を生成する Names.nodeUniqueIdNames.uniqueId なども同様です。


では、具体的なリソースの例を見ていきましょう。

※注意: 以下で紹介するリソースの例は、置換が発生するリソースの一部です。すべてを網羅しているわけではありません。他にも置換が発生するリソースはあるので注意してください。


SecurityGroup

まずSecurityGroupですが、CloudFormation での GroupDescriptionという、Replacement となるプロパティの変更が原因で置換が発生します。

SecurityGroupPropsdescription に明示的に値を指定している場合は置換が発生しないのですが、未指定の場合は Construct のパスである this.node.path が使用されます。Stage に移行するとパスが変わるため、結果として GroupDescription の値が変わってしまいます。

原因部分の CDK 内部のコードは以下のようになっています。

packages/aws-cdk-lib/aws-ec2/lib/security-group.ts

const groupDescription = props.description || this.node.path;


SecurityGroup の Ingress/Egress ルール

SecurityGroupIngress/Egress ルールでも置換が発生します。

具体的には、addIngressRule (SecurityGroupIngress) や、allowAllOutboundfalse のときの addEgressRule (SecurityGroupEgress) で置換が発生します。

またこれは、論理 ID の変更が原因です。

const sg1 = new SecurityGroup(this, 'SecurityGroup1', {
  vpc: vpc,
  allowAllOutbound: true,
});
const sg2 = new SecurityGroup(this, 'SecurityGroup2', {
  vpc: vpc,
  allowAllOutbound: false, // true だと addEgressRule をしてもルールが追加されない
});

sg2.addIngressRule(sg1, Port.tcp(80), 'Allow traffic from sg1 to sg2 on port 80');
sg2.addEgressRule(sg1, Port.tcp(80), 'Allow traffic from sg2 to sg1 on port 80');

原因となる CDK 内部のコードは以下です。

this.uniqueIdpeer.uniqueId では内部的に Names.nodeUniqueId(this.node) という、コンストラクトパスをもとにユニークな ID を出力するメソッドが使用されています。

この値が最終的にリソースの Construct ID に渡されて論理 ID として使用されるため、Stage に移行するとこの結果が変わり、論理 ID が変更されることで置換が発生します。

packages/aws-cdk-lib/aws-ec2/lib/security-group.ts

  protected determineRuleScope(
    peer: IPeer,
    connection: Port,
    fromTo: 'from' | 'to',
    remoteRule?: boolean): RuleScope {
    if (remoteRule && SecurityGroupBase.isSecurityGroup(peer) && differentStacks(this, peer)) {
      // Reversed
      const reversedFromTo = fromTo === 'from' ? 'to' : 'from';
      return { scope: peer, id: `${this.uniqueId}:${connection} ${reversedFromTo}` };
    } else {
      // Regular (do old ID escaping to in order to not disturb existing deployments)
      return { scope: this, id: `${fromTo} ${this.renderPeer(peer)}:${connection}`.replace('/', '_') };
    }
  }

  private renderPeer(peer: IPeer) {
    if (Token.isUnresolved(peer.uniqueId)) {
      // Need to return a unique value each time a peer
      // is an unresolved token, else the duplicate skipper
      // in `sg.addXxxRule` can detect unique rules as duplicates
      return this.peerAsTokenCount++ ? `'{IndirectPeer${this.peerAsTokenCount}}'` : '{IndirectPeer}';
    } else {
      return peer.uniqueId;
    }
  }


CustomResource の Provider 内の StepFunctions の LogGroup

CustomResource の Provider という Construct では、isCompleteHandler が指定されている場合、内部で StepFunctions の StateMachine が作成されます。

const isCompleteHandler = new Function(this, 'MyFunction', {
  runtime: Runtime.NODEJS_22_X,
  handler: 'index.handler',
  code: Code.fromAsset(path.join(__dirname, 'lambda')),
});
new Provider(this, 'Provider', {
  onEventHandler,
  isCompleteHandler,
});


この StateMachine の LogGroup では、ロググループ名(logGroupName)、つまり物理名this.node.addr が使用されています。

また、this.node.addr はコンストラクトパスをもとに生成されるハッシュ値です。


つまり Stage に移行するとこの値が変わるため、物理名が変わり、置換が発生します。

原因となる CDK 内部のコードは以下です。

packages/aws-cdk-lib/custom-resources/lib/provider-framework/waiter-state-machine.ts

const logGroup =
  logOptions?.destination ??
  new LogGroup(this, 'LogGroup', {
    // Log group name should start with `/aws/vendedlogs/` to not exceed Cloudwatch Logs Resource Policy
    // size limit.
    // https://docs.aws.amazon.com/step-functions/latest/dg/bp-cwl.html
    //
    // By using the auto-generated name of the Lambda created in the `Provider` that calls this
    // `WaiterStateMachine` construct, even if the `Provider` (or its parent) is deleted and then
    // created again, the log group name will not duplicate previously created one with removal
    // policy `RETAIN`. This is because that the Lambda will be re-created again with auto-generated name.
    // The `node.addr` is also used to prevent duplicate names no matter how many times this construct
    // is created in the stack. It will not duplicate if called on other stacks.
    logGroupName: `/aws/vendedlogs/states/waiter-state-machine-${this.isCompleteHandler.functionName}-${this.node.addr}`,
  });


Lambda の Event Source

Lambda Function の addEventSource メソッドで SqsEventSourceSnsEventSource などを使用している場合も、Stage 移行によって論理 ID が変わるため、置換が発生します。他にも、S3EventSourceDynamoEventSourceKinesisEventSource なども同様です。

const func = new Function(this, 'MyFunction', {
  runtime: Runtime.NODEJS_22_X,
  handler: 'index.handler',
  code: Code.fromAsset(path.join(__dirname, 'lambda')),
});

func.addEventSource(new SqsEventSource(queue));
func.addEventSource(new SnsEventSource(topic));


しかし、addEventSourceMapping メソッドや EventSourceMapping Construct を使用している場合は置換は発生しません。

func.addEventSourceMapping('MyEventSourceMapping', {
  eventSourceArn: 'arn:aws:sqs:us-east-1:123456789012:MyQueue',
});

new EventSourceMapping(this, id, {
  target: func,
  eventSourceArn: 'arn:aws:sqs:us-east-1:123456789012:MyQueue',
});


該当する CDK 内部のコードは以下のようになっています。

SqsEventSource では Names.nodeUniqueId が使用されており、これが EventSourceMapping の Construct ID に渡され、論理 ID の生成に使用されます。つまり、Stage に移行するとこの値が変わり、論理 ID が変更されるため置換が発生します。

packages/aws-cdk-lib/aws-lambda-event-sources/lib/sqs.ts

  public bind(target: lambda.IFunction) {
    const eventSourceMapping = target.addEventSourceMapping(`SqsEventSource:${Names.nodeUniqueId(this.queue.node)}`, {
      batchSize: this.props.batchSize,
      maxBatchingWindow: this.props.maxBatchingWindow,
      maxConcurrency: this.props.maxConcurrency,
      reportBatchItemFailures: this.props.reportBatchItemFailures,
      enabled: this.props.enabled,
      eventSourceArn: this.queue.queueArn,
      filters: this.props.filters,
      filterEncryption: this.props.filterEncryption,
      metricsConfig: this.props.metricsConfig,
    });


また、上記の SqsEventSource では AWS::Lambda::EventSourceMapping の論理 ID が変更されるのですが、SnsEventSource では AWS::Lambda::Permission の論理 ID が変更されます。

これは EventSource というより、内部で呼び出されている LambdaSubscription クラスの実装に起因します。ここでも、Names.nodeUniqueId が使用されています。

packages/aws-cdk-lib/aws-sns-subscriptions/lib/lambda.ts

  public bind(topic: sns.ITopic): sns.TopicSubscriptionConfig {
    // Create subscription under *consuming* construct to make sure it ends up
    // in the correct stack in cases of cross-stack subscriptions.
    if (!Construct.isConstruct(this.fn)) {
      throw new ValidationError('The supplied lambda Function object must be an instance of Construct', topic);
    }

    this.fn.addPermission(`AllowInvoke:${Names.nodeUniqueId(topic.node)}`, {
      sourceArn: topic.topicArn,
      principal: new iam.ServicePrincipal('sns.amazonaws.com'),
    });


SNS Topic の Lambda Subscription

上述の通り、SNS Topic の addSubscription メソッドで LambdaSubscription (aws-cdk-lib/aws-sns-subscriptions) を使用している場合でも、Stage 移行により置換が発生します。

const topic = new Topic(this, 'MyTopic');
const func = new Function(this, 'MyFunction', {
  runtime: Runtime.NODEJS_22_X,
  handler: 'index.handler',
  code: Code.fromAsset(path.join(__dirname, 'lambda')),
});

topic.addSubscription(new LambdaSubscription(func));


S3 Bucket の Lambda Notification (Destination)

S3 BucketaddEventNotification メソッドで LambdaDestination (aws-cdk-lib/aws-s3-notifications) を使用している場合でも、Stage 移行により上記と同じような AWS::Lambda::Permission の置換が発生します。

const bucket = new Bucket(this, 'MyBucket');
const func = new Function(this, 'MyFunction', {
  runtime: Runtime.NODEJS_22_X,
  handler: 'index.handler',
  code: Code.fromAsset(path.join(__dirname, 'lambda')),
});

bucket.addEventNotification(EventType.OBJECT_CREATED, new LambdaDestination(func));


RDS の Credentials における fromGeneratedSecret

RDS / Aurora の DatabaseCluster などで、 credentialsfromGeneratedSecret を使用している場合も、Stage 移行によって論理 ID が変わるため置換が発生します。ここでは、SecretsManager の Secret の論理 ID が変更されます。

また、fromPasswordfromUsernamefromSecret などのメソッドを使用している場合は置換は発生しません。

new DatabaseCluster(this, 'Cluster', {
  engine,
  vpc,
  writer,
  credentials: Credentials.fromGeneratedSecret('clusteradmin'),
});


該当する CDK 内部のコードは以下です。

具体的には、内部で呼ばれる DatabaseSecret の中で、Names.uniqueIdを使用した値で論理 ID を上書き(overrideLogicalId)しています。

packages/aws-cdk-lib/aws-rds/lib/database-secret.ts

if (props.replaceOnPasswordCriteriaChanges) {
  const hash = md5hash(
    JSON.stringify({
      // Use here the options that influence the password generation.
      // If at some point we add other password customization options
      // they should be added here below (e.g. `passwordLength`).
      excludeCharacters,
    }),
  );
  const logicalId = `${Names.uniqueId(this)}${hash}`;

  const secret = this.node.defaultChild as secretsmanager.CfnSecret;
  secret.overrideLogicalId(logicalId.slice(-255)); // Take last 255 chars
}


まとめ

基本的には Stage に移行しても論理 ID は変わらないため置換は発生しませんが、一部のリソースでは発生する可能性があるので、スナップショットテストなどで確認しておくと良いでしょう。