📝

ダウンタイムを抑えてCDKに移行

に公開

はじめに

最近、既存のコードを AWS CDK に移行するプロジェクトに取り組みました。
AWS で laC の経験はそれなりに経験あるのですが、laC のコード移行は初めてで色々と苦労しました。
この記事では、大変だったこと学んだことを共有しようと思います。
なお CDK の基本な使い方などにはあまり触れないのでご了承ください。

関係する技術・ツール

  • AWS CDK
  • AWS CloudFormation
  • serverless framework v3

移行にあたっての前提

移行の大変なところは、移行前の状態に大きく依存することです。
移行前のコードは serverless framework で書かれていました。
それを踏まえて、今回の移行にあたっての前提となる要件は下記でした。

  • 既存のスタックは削除しない。
  • 既存のリソースは削除しない。またリソースの設定は原則変更しない。

CloudFormation がキモである

まず簡単に移行に際して serverless framework のほうの仕様をおさえておくと、serverkess framework はいわゆる 3rd party のツールであり、serverless.yml という独自の仕様で laC を記述します。
デプロイする際には記述した serverless.yml をもとに CloudFormation のテンプレートを生成し、CloudFormation を使ってリソースをスタックと呼ばれるまとまりでデプロイします。

一方で、移行先である AWS CDK は AWS 公式のツールであり、プログラミング言語(typescript や python など)で laC を記述します。
デプロイする際には、記述したコードは CloudFormation のテンプレートに変換され、CloudFormation を使ってリソースをデプロイします。

つまりここで重要なのは、両者とも最終的には CloudFormation を使ってリソースをデプロイするという点です。
両者とも CloudFormation という共通のインターフェースになるため、これを利用することが移行をスムーズにするポイントになります。

なぜなら、記述の仕方がどうであれ生成された CloudFormation が同じであれば、全く同じリソースが作成されると言えるためです。
これにより、移行の過程で間違ってリソースを削除したり、設定を変更したりするリスクを軽減できます。

移行作業のフロー

大まかな移行のフローは下記の通りです。
必要なツールはインストールされている前提です。

  1. cdk migrate で土台を作る
  2. コードを修正する
  3. cdk diff で差分を確認
  4. 既存のスタックに対してデプロイ

ステップにするとあっさりなのですが、実はいろいろと注意点がありました。
以下でフローと加えて説明していきたいと思います。

1. cdk migrate で土台を作る

AWS CDK には CLI があります。
AWS CDK の CLI は npm でインストールできます。

npm install -g aws-cdk

CLI にはデプロイコマンドはもちろん、CDK への移行するための便利なコマンドもあります。
このコマンドを実行すると指定したスタックまたは CloudFormation テンプレートなどを元に、CDK のコードの土台を自動生成してくれます。一から作るより圧倒的に楽です。便利なものはつかわない手はないのでこちらを活用しましょう。
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/migrate.html
例えば、sample-sls-dev スタックを元に typescript で CDK のプロジェクトを作成する場合、下記のようにします。

cdk migrate --from-stack sample-sls-dev --stack-name sample-sls-dev --language typescript

⚠️ サポートしていないリソースがあると cdk migrate できない

ただし、cdk migrate には注意点があります。
cdk migrate は全てのリソースをサポートしていないことです。
試した感じ、少なくとも CloudFormation のカスタムリソースはサポートされおらず、cdk migrate が失敗しました。
カスタムリソースについて詳しくは下記を参照ください。
https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/template-custom-resources.html

それでも cdk migrate を使用したければ、CloudFormation テンプレートを元に cdk migrate するとよいと思います。
CloudFormation テンプレートは AWS マネジメントコンソールの CloudFormation のスタックの画面からコピーできます。

これをローカル PC に保存して、該当のカスタムリソースを取り除くと cdk migrate ができると思います。

テンプレートから cdk migrate する場合、下記のようにオプションを指定します。

cdk migrate --from-path <path-to-template> --language typescript

2. コードを修正する

cdk migrate でコードの土台ができたら、コードを修正していきます。
下記のような構造でフォルダができていると思います。

..
├── bin
│   └── sample-sls-dev.ts
├── cdk.json
├── lib
│   └── sample-sls-dev-stack.ts
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json

この sample-sls-dev-stack.ts を修正してきますので開いてみましょう。
下記は lambda を 1 つ持つシンプルなスタックの例です。

cdk migrate で生成されたスタックコード例
import * as cdk from "aws-cdk-lib";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as logs from "aws-cdk-lib/aws-logs";
import * as s3 from "aws-cdk-lib/aws-s3";

export interface SampleSlsDevStackProps extends cdk.StackProps {}

/**
 * The AWS CloudFormation template for this Serverless application
 */
export class SampleSlsDevStack extends cdk.Stack {
  public readonly serverlessDeploymentBucketName;
  /**
   * Current Lambda function version
   */
  public readonly helloLambdaFunctionQualifiedArn;
  /**
   * URL of the service endpoint
   */
  public readonly serviceEndpoint;

  public constructor(
    scope: cdk.App,
    id: string,
    props: SampleSlsDevStackProps = {},
  ) {
    super(scope, id, props);

    // Resources
    const apiGatewayRestApi = new apigateway.CfnRestApi(
      this,
      "ApiGatewayRestApi",
      {
        name: "sample-sls-dev",
        endpointConfiguration: {
          types: ["EDGE"],
        },
        // policy: "",
        minimumCompressionSize: 1024,
      },
    );

    const helloLogGroup = new logs.CfnLogGroup(this, "HelloLogGroup", {
      logGroupName: "/aws/lambda/sample-sls-dev-hello",
    });

    const iamRoleLambdaExecution = new iam.CfnRole(
      this,
      "IamRoleLambdaExecution",
      {
        assumeRolePolicyDocument: {
          Version: "2012-10-17",
          Statement: [
            {
              Effect: "Allow",
              Principal: {
                Service: ["lambda.amazonaws.com"],
              },
              Action: ["sts:AssumeRole"],
            },
          ],
        },
        policies: [
          {
            policyName: ["sample-sls", "dev", "lambda"].join("-"),
            policyDocument: {
              Version: "2012-10-17",
              Statement: [
                {
                  Effect: "Allow",
                  Action: [
                    "logs:CreateLogStream",
                    "logs:CreateLogGroup",
                    "logs:TagResource",
                  ],
                  Resource: [
                    `arn:${this.partition}:logs:${this.region}:${this.account}:log-group:/aws/lambda/sample-sls-dev*:*`,
                  ],
                },
                {
                  Effect: "Allow",
                  Action: ["logs:PutLogEvents"],
                  Resource: [
                    `arn:${this.partition}:logs:${this.region}:${this.account}:log-group:/aws/lambda/sample-sls-dev*:*:*`,
                  ],
                },
              ],
            },
          },
        ],
        path: "/",
        roleName: ["sample-sls", "dev", this.region, "lambdaRole"].join("-"),
      },
    );

    const serverlessDeploymentBucket = new s3.CfnBucket(
      this,
      "ServerlessDeploymentBucket",
      {
        bucketEncryption: {
          serverSideEncryptionConfiguration: [
            {
              serverSideEncryptionByDefault: {
                sseAlgorithm: "AES256",
              },
            },
          ],
        },
      },
    );

    const apiGatewayMethodHelloPostApplicationJsonModel =
      new apigateway.CfnModel(
        this,
        "ApiGatewayMethodHelloPostApplicationJsonModel",
        {
          restApiId: apiGatewayRestApi.ref,
          contentType: "application/json",
          schema: {
            type: "object",
            properties: {
              name: {
                type: "string",
              },
            },
            required: ["name"],
          },
        },
      );

    const apiGatewayResourceHello = new apigateway.CfnResource(
      this,
      "ApiGatewayResourceHello",
      {
        parentId: apiGatewayRestApi.attrRootResourceId,
        pathPart: "hello",
        restApiId: apiGatewayRestApi.ref,
      },
    );

    const apiGatewaySampleslsRequestValidator =
      new apigateway.CfnRequestValidator(
        this,
        "ApiGatewaySampleslsRequestValidator",
        {
          restApiId: apiGatewayRestApi.ref,
          validateRequestBody: true,
          validateRequestParameters: true,
          name: "sample-sls-dev | Validate request body and querystring parameters",
        },
      );

    const helloLambdaFunction = new lambda.CfnFunction(
      this,
      "HelloLambdaFunction",
      {
        code: {
          s3Bucket: serverlessDeploymentBucket.ref,
          s3Key:
            "serverless/sample-sls/dev/1757211030679-2025-09-07T02:10:30.679Z/hello.zip",
        },
        handler: "src/functions/hello/handler.main",
        runtime: "nodejs20.x",
        functionName: "sample-sls-dev-hello",
        memorySize: 1024,
        timeout: 6,
        environment: {
          variables: {
            AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
            NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
          },
        },
        role: iamRoleLambdaExecution.attrArn,
      },
    );
    helloLambdaFunction.addDependency(helloLogGroup);

    const serverlessDeploymentBucketPolicy = new s3.CfnBucketPolicy(
      this,
      "ServerlessDeploymentBucketPolicy",
      {
        bucket: serverlessDeploymentBucket.ref,
        policyDocument: {
          Statement: [
            {
              Action: "s3:*",
              Effect: "Deny",
              Principal: "*",
              Resource: [
                [
                  "arn:",
                  this.partition,
                  ":s3:::",
                  serverlessDeploymentBucket.ref,
                  "/*",
                ].join(""),
                [
                  "arn:",
                  this.partition,
                  ":s3:::",
                  serverlessDeploymentBucket.ref,
                ].join(""),
              ],
              Condition: {
                Bool: {
                  "aws:SecureTransport": false,
                },
              },
            },
          ],
        },
      },
    );

    const helloLambdaPermissionApiGateway = new lambda.CfnPermission(
      this,
      "HelloLambdaPermissionApiGateway",
      {
        functionName: helloLambdaFunction.attrArn,
        action: "lambda:InvokeFunction",
        principal: "apigateway.amazonaws.com",
        sourceArn: [
          "arn:",
          this.partition,
          ":execute-api:",
          this.region,
          ":",
          this.account,
          ":",
          apiGatewayRestApi.ref,
          "/*/*",
        ].join(""),
      },
    );

    const helloLambdaVersion9J2f4O4dcjVum6gnWrAvCoPzhfy6ni4LsIijQrxUm =
      new lambda.CfnVersion(
        this,
        "HelloLambdaVersion9J2f4O4DCJVum6gnWrAVCoPzhfy6ni4LsIijQrxUM",
        {
          functionName: helloLambdaFunction.ref,
          codeSha256: "NubeOc4GWBGWA1ZkuNpBKMhl5p7AOjPXcoOR3b28I78=",
        },
      );
    helloLambdaVersion9J2f4O4dcjVum6gnWrAvCoPzhfy6ni4LsIijQrxUm.cfnOptions.deletionPolicy =
      cdk.CfnDeletionPolicy.RETAIN;

    const apiGatewayMethodHelloPost = new apigateway.CfnMethod(
      this,
      "ApiGatewayMethodHelloPost",
      {
        httpMethod: "POST",
        requestParameters: {},
        resourceId: apiGatewayResourceHello.ref,
        restApiId: apiGatewayRestApi.ref,
        apiKeyRequired: false,
        authorizationType: "NONE",
        integration: {
          integrationHttpMethod: "POST",
          type: "AWS_PROXY",
          uri: [
            "arn:",
            this.partition,
            ":apigateway:",
            this.region,
            ":lambda:path/2015-03-31/functions/",
            helloLambdaFunction.attrArn,
            "/invocations",
          ].join(""),
        },
        methodResponses: [],
        requestValidatorId: apiGatewaySampleslsRequestValidator.ref,
        requestModels: {
          "application/json": apiGatewayMethodHelloPostApplicationJsonModel.ref,
        },
      },
    );
    apiGatewayMethodHelloPost.addDependency(helloLambdaPermissionApiGateway);

    const apiGatewayDeployment1757211029017 = new apigateway.CfnDeployment(
      this,
      "ApiGatewayDeployment1757211029017",
      {
        restApiId: apiGatewayRestApi.ref,
        stageName: "dev",
      },
    );
    apiGatewayDeployment1757211029017.addDependency(apiGatewayMethodHelloPost);

    // Outputs
    this.serverlessDeploymentBucketName = serverlessDeploymentBucket.ref;
    new cdk.CfnOutput(this, "CfnOutputServerlessDeploymentBucketName", {
      key: "ServerlessDeploymentBucketName",
      exportName: "sls-sample-sls-dev-ServerlessDeploymentBucketName",
      value: this.serverlessDeploymentBucketName!.toString(),
    });
    this.helloLambdaFunctionQualifiedArn =
      helloLambdaVersion9J2f4O4dcjVum6gnWrAvCoPzhfy6ni4LsIijQrxUm.ref;
    new cdk.CfnOutput(this, "CfnOutputHelloLambdaFunctionQualifiedArn", {
      key: "HelloLambdaFunctionQualifiedArn",
      description: "Current Lambda function version",
      exportName: "sls-sample-sls-dev-HelloLambdaFunctionQualifiedArn",
      value: this.helloLambdaFunctionQualifiedArn!.toString(),
    });
    this.serviceEndpoint = [
      "https://",
      apiGatewayRestApi.ref,
      ".execute-api.",
      this.region,
      ".",
      this.urlSuffix,
      "/dev",
    ].join("");
    new cdk.CfnOutput(this, "CfnOutputServiceEndpoint", {
      key: "ServiceEndpoint",
      description: "URL of the service endpoint",
      exportName: "sls-sample-sls-dev-ServiceEndpoint",
      value: this.serviceEndpoint!.toString(),
    });
  }
}

既に現在の CloudFormation のコードがほぼそのまま反映されていると思います。
ちなみに、出来上がりは L1 コンストラクタというやつで書かれていると思います。
L1 とは AWS CDK でほぼネイティブ CloudFormation テンプレートと書き方のテイストが似ているレベルのコンストラクタです。
L1 以外にも L2、L3 といったレベルのコンストラクタがありますが、基本的には L1 が最も記述量が多くなります。
なので L2 以上を使用することで CDK のメリットがより享受できると思います。
詳しくは下記へどうぞ。

https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/constructs.html

基本には cdk migrate で生成されたコードでもデプロイはできると思いますが、ケースバイケースで修正する必要があります。
挙げたらキリがないのですが、対応に悩んだケースを挙げておきます。

具体的なケース

lambda を L2 に書き換える

serverless framewotk はサーバーレスアプリケーションの開発に特化したツールだと思います。なので、lambda を使うケースが多いと思います。
前述した通り、cdk migrate で生成されたコードは L1 で書かれています。
しかし、L1 だと Lambda のデプロイサイクル回すのが大変なので、lambda を L2 に書き換えることをお勧めします。

例えば、serverless framework で作成した lambda が cdk migrate で下記のような CDK のコードで出力されているとします。

cdk migrate で生成された Lambda 定義例
const helloLambdaFunction = new lambda.CfnFunction(
  this,
  "HelloLambdaFunction",
  {
    code: {
      s3Bucket: serverlessDeploymentBucket.ref,
      s3Key:
        "serverless/sample-sls/dev/1757211030679-2025-09-07T02:10:30.679Z/hello.zip",
    },
    handler: "src/functions/hello/handler.main",
    runtime: "nodejs20.x",
    functionName: "sample-sls-dev-hello",
    memorySize: 1024,
    timeout: 6,
    environment: {
      variables: {
        AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
        NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
      },
    },
    role: iamRoleLambdaExecution.attrArn,
  },
);

helloLambdaFunction.addDependency(helloLogGroup);

const helloLambdaVersion9J2f4O4dcjVum6gnWrAvCoPzhfy6ni4LsIijQrxUm =
  new lambda.CfnVersion(
    this,
    "HelloLambdaVersion9J2f4O4DCJVum6gnWrAVCoPzhfy6ni4LsIijQrxUM",
    {
      functionName: helloLambdaFunction.ref,
      codeSha256: "NubeOc4GWBGWA1ZkuNpBKMhl5p7AOjPXcoOR3b28I78=",
    },
  );
helloLambdaVersion9J2f4O4dcjVum6gnWrAvCoPzhfy6ni4LsIijQrxUm.cfnOptions.deletionPolicy =
  cdk.CfnDeletionPolicy.RETAIN;

このコードの問題点としては下記が挙げられます。

  • ビルドした lambda を zip して aws 上にアップロードする仕組みを自分で用意する必要がある。
  • version 発行にはコードの差分をハッシュで計算する必要があるが、自分で計算してコードに埋め込む必要がある。

いやあ、めんどくさいですね。正直自前でやりたくないです。
なので L2 を使ってなるべくラクをしましょう。

修正箇所をおさえて、L2 で書くと下記のような感じになります。

L2 に書き換えた Lambda 定義例
const helloLambdaFunction = new lambda.Function(this, "HelloLambdaFunction", {
  handler: "src/functions/hello/handler.main",
  code: lambda.Code.fromAsset("lambda/hello.zip"),
  runtime: lambda.Runtime.NODEJS_20_X,
  functionName: "sample-sls-dev-hello",
  memorySize: 1024,
  timeout: cdk.Duration.seconds(6),
  environment: {
    AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
    NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
  },
  role: iam.Role.fromRoleArn(
    this,
    "LambdaRole",
    iamRoleLambdaExecution.attrArn,
  ),
});

const lambdaVersion = helloLambdaFunction.currentVersion;

const cfnHelloLambdaFunction = helloLambdaFunction.node
  .defaultChild as lambda.CfnFunction;

cfnHelloLambdaFunction.overrideLogicalId("HelloLambdaFunction");

cfnHelloLambdaFunction.addDependency(helloLogGroup);

上記にようにすると、下記のようなメリットがあります。

  • lambda のアップロードは CDK が自動でやってくれる。
  • version の発行と判定は CDK が自動でやってくれる。

overrideLogicalId をするのは L2 を使うと論理 ID が変わってしまうためです。
CloudFormation の仕様上、デプロイ前後で論理 ID の変更は全く異なるリソースとして扱われて削除されてしまうので、論理 ID を変えないように注意する必要があります。
余談ですが、lambda の実装が typescript なら NodejsFunction という L2 コンストラクタを使うとビルドも自動で便利です。

カスタムリソースをダウンタイムなしに移行する

個人的には、カスタムリソースを使うのはレアケースだと考えていますが、serverless framework の裏側の仕組みで無意識にカスタムリソースが組み込まれていることがあります。
私が把握しているケースだと、下記の場合です。

  • 既存の S3 バケットに対して、トリガー(S3 コンソールで見れるほう)を設定している場合
  • 既存の Cognito に対して、トリガーを設定している場合

これは CloudFormation の仕様上、特別なことをしない限り、S3 と Cognito には作成と同時にしかトリガーを設定できないためです。
ではどのようにして既に存在するリソースに対してトリガーを設定するかというと、CloudFormation のカスタムリソースを使うことで実現しています。

簡単にいうと、カスタムリソースはデプロイ時に実行できる Lambda 関数です。Lambda であれでプログラミング言語でリソースを操作する AWS の API を呼び出せます。この仕組みを使って既存の S3 や Cognito にトリガーを参照してトリガー設定を更新しているというわけです。

仕組みが分かったところで、何が問題なのかというと、カスタムリソースという名前の通りカスタムリソースの設計には自由度がそれなりにあって、serverless framework で作成されるカスタムリソースと AWS CDK で作成されるカスタムリソースの実装が異なる点です。具体的にはカスタムリソースを実行する際の引数が異なります。するとほぼそのままコピペで移行というわけにはいきません。

しかし、serverless framework から移行する目的なのに serverless framework の実装のまま使うのは微妙ですし、カスタムリソースの実装を自分で git 管理してデプロイ時に使用するのもいけてない気がします。なので、AWS CDK 実装のカスタムリソースをどうにかして使いたいな~と考えていました。

これに対しては node_modules から CDK のカスタムリソース実装の Lambda を引っ張り出してきて、スタックの定義でその Lambda を使うように修正しました。

どういうことか既存の s3 バケットに対して、トリガーを設定するカスタムリソースの例で確認しましょう。

まず、serverless framework で生成された CloudFormation のカスタムリソースの例は下記のような感じになっています。

serverless framework で生成された CloudFormation のカスタムリソース例
{
  "TriggerLambdaCustomS31": {
    "Type": "Custom::S3",
    "Version": 1,
    "DependsOn": [
      "TriggerLambdaLambdaFunction",
      "CustomDashresourceDashexistingDashs3LambdaFunction"
    ],
    "Properties": {
      "ServiceToken": {
        "Fn::GetAtt": [
          "CustomDashresourceDashexistingDashs3LambdaFunction",
          "Arn"
        ]
      },
      "FunctionName": "sample-sls-dev-triggerLambda",
      "BucketName": "test-sample-bucket",
      "BucketConfigs": [
        {
          "Event": "s3:ObjectCreated:*",
          "Rules": [
            {
              "Prefix": "uploads/"
            }
          ]
        }
      ]
    }
  },
  "CustomDashresourceDashexistingDashs3LambdaFunction": {
    "Type": "AWS::Lambda::Function",
    "Properties": {
      "Code": {
        "S3Bucket": {
          "Ref": "ServerlessDeploymentBucket"
        },
        "S3Key": "serverless/sample-sls/dev/1757223273105-2025-09-07T05:34:33.105Z/custom-resources.zip"
      },
      "FunctionName": "sample-sls-dev-custom-resource-existing-s3",
      "Handler": "s3/handler.handler",
      "MemorySize": 1024,
      "Runtime": "nodejs18.x",
      "Timeout": 180,
      "Role": {
        "Fn::GetAtt": ["IamRoleCustomResourcesLambdaExecution", "Arn"]
      }
    },
    "DependsOn": ["IamRoleCustomResourcesLambdaExecution"]
  }
}

TriggerLambdaCustomS31 がカスタムリソース定義で、CustomDashresourceDashexistingDashs3LambdaFunction がカスタムリソースによって実行される Lambda 関数の定義です。
これを CDK に移行する際に下記のように修正します。

cdk 実装のカスタムリソースに修正した例
const triggerLambda = new lambda.Function(this, "TriggerLambdaLambdaFunction", {
  functionName: "sample-sls-dev-triggerLambda",
  code: lambda.Code.fromAsset("lambda/triggerLambda.zip"),
  handler: "src/functions/triggerLambda/handler.main",
  runtime: lambda.Runtime.NODEJS_20_X,
  memorySize: 1024,
  timeout: cdk.Duration.seconds(6),
  environment: {
    AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
    NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
  },
  role: iam.Role.fromRoleArn(
    this,
    "LambdaRole",
    iamRoleLambdaExecution.attrArn,
  ),
});

triggerLambda.addPermission("TriggerLambdaPermission", {
  action: "lambda:InvokeFunction",
  principal: new iam.ServicePrincipal("s3.amazonaws.com"),
  sourceArn: `arn:${this.partition}:s3:::<バケット名>`, //トリガー設定する既存のバケット名
});

const customDashresourceDashexistingDashs3LambdaFunction = new lambda.Function(
  this,
  "CustomDashresourceDashexistingDashs3LambdaFunction",
  {
    code: lambda.Code.fromAsset(
      //node_modulesの中からCDKのカスタムリソースの実装を引っ張り出してくる
      "node_modules/aws-cdk-lib/custom-resource-handlers/dist/aws-s3/notifications-resource-handler",
    ),
    handler: "index.handler",
    runtime: lambda.Runtime.PYTHON_3_11, //serverless frameworkの実装はnodejsだったが、CDKの実装はpythonなので注意
    functionName: "sample-sls-dev-custom-resource-existing-s3",
    memorySize: 1024,
    timeout: cdk.Duration.seconds(180),
    role: iam.Role.fromRoleArn(
      this,
      "customRole",
      iamRoleCustomResourcesLambdaExecution.attrArn,
    ),
  },
);

const cfnCustomDashresourceDashexistingDashs3LambdaFunction =
  customDashresourceDashexistingDashs3LambdaFunction.node
    .defaultChild as lambda.CfnFunction;

cfnCustomDashresourceDashexistingDashs3LambdaFunction.overrideLogicalId(
  "CustomDashresourceDashexistingDashs3LambdaFunction",
);

const customS3Notification = new CustomResource(
  this,
  "TriggerLambdaCustomS31",
  {
    resourceType: "Custom::S3",
    serviceToken:
      customDashresourceDashexistingDashs3LambdaFunction.functionArn,
    properties: {
      /**
       * serverless frameworkの実装とCDKの実装で引数が異なるので注意
       */
      BucketName: "<バケット名>", // トリガー設定する既存のバケット名。
      NotificationConfiguration: {
        LambdaFunctionConfigurations: [
          {
            Events: ["s3:ObjectCreated:*"],
            LambdaFunctionArn:
              customDashresourceDashexistingDashs3LambdaFunction.functionArn,
          },
        ],
      },
      Managed: false,
      SkipDestinationValidation: false,
    },
  },
);

const customResource = customS3Notification.node
  .defaultChild as cdk.CfnCustomResource;
customResource.overrideLogicalId("TriggerLambdaCustomS31");

例の都合上トリガーターゲットのLambdaを加えてますが、ここではLambda自体の定義はそんなに重要ではありません。
ポイントは下記です。

  • lambda のランタイムを python に変更
  • lambda の code をnode_modules/aws-cdk-lib/custom-resource-handlers/dist/aws-s3/notifications-resource-handlerに変更
  • カスタムリソースのプロパティを CDK 実装の引数に合うように変更
  • s3トリガーのターゲットLambdaにS3がInvokeFunctionできるようにPermissionを追加
    • serverless framework の実装ではカスタムリソースの中で直接コマンドを叩いているようなので、cfn上には履歴が一切残っていません。Permissionがないと同一ソースから新規スタックを作成しようとするとエラーになるので注意が必要です。
⚠️ さらに困ったこと

実はカスタムリソースの移行にはもう一つ困ったことがありました。
それは、移行する作業のためにカスタムリソースが必ず実行されてしまうことです。

前述しましたが カスタムリソースは serverless framework の実装と CDK の実装で引数が異なります。
もし、既存の serverless framework のカスタムリソースを CDK のカスタムリソースに変えようとすると、このタイミングで必ずカスタムリソースが実行されてしまいます。これはカスタムリソースの仕様のためどうにもなりません。
それの何が問題かというと、既にトリガーが設定されているにも関わらず、再度トリガー設定をしようとしてしまうことです。しかし、同じ設定のトリガーは設定できないため、デプロイが必ず失敗してしまいます。

そのためさらに困まったのですが、

「実行はされてしまうのであればすぐに停止するようにすればよいのでは?」

という思いつきで cdk のカスタムリソースの Lambda 関数のコードを修正する方針にしました。

とはいえ直接 node_modules のコードを修正するのは微妙なので、node_modules からコードをコピーしてきてひっぱり出しました。
冒頭だけ抜粋すると下記のような感じです。

カスタムリソースで実行するLambdaの修正例
import boto3
import json
import logging
import urllib.request

s3 = boto3.client("s3")

EVENTBRIDGE_CONFIGURATION = 'EventBridgeConfiguration'
CONFIGURATION_TYPES = ["TopicConfigurations", "QueueConfigurations", "LambdaFunctionConfigurations"]

def handler(event: dict, context):
  response_status = "SUCCESS"
  error_message = ""
  # 初回はこれでデプロイする。そして2回目以降はオリジナルのcdk実装にすり替える
  if event["RequestType"] =="Update":
    print("Update event received, but this is a custom resource handler that does not support updates.")
    submit_response(event, context, response_status, error_message)
    return

 # 以下省略

lambda のハンドラーの冒頭で即座に return するようにしています。
これで移行の際にカスタムリソースが実行されてもすぐに停止するようにできます。

あとは、初回デプロイ時は修正したコードを使用するようにします。

初回デプロイ時に修正したコードを使用する例
const customDashresourceDashexistingDashs3LambdaFunction = new lambda.Function(
  this,
  "CustomDashresourceDashexistingDashs3LambdaFunction",
  {
    code: lambda.Code.fromAsset(
      "temp-custom-resource-handler" //コピーしてきたコードを配置したディレクトリを指定
    ),
// 省略

このようにすれば、カスタムリソースの移行時に、カスタムリソースが実行されますが、すぐに停止するので、トリガーの設定の重複でエラーとならずデプロイが成功するようになります。

これでまず 1 回目のデプロイが成功したら、上記の Lambda 関数のコードをもとの cdk の実装に戻して、再度デプロイすれば OK というわけです。
以降は cdk のカスタムリソースが実行されるので、問題なくトリガーの設定更新ができるようになります。

3. cdk diff で差分を確認

ここまでできたら、cdk diff コマンドを実行しましょう。
diff をすると、作成した cdk コード(を元に生成される CloudFormation)と既存のスタックのソースとなった最新の CloudFormation の差分を確認できます。
これにより、git のように変更差分が意図通りであるかどうかの確認ができます。

cdk diff sample-sls-dev

diff が妥当か判断基準

diff の結果、差分が出ることがあります。
差分が出た場合、下記のような観点で妥当かどうかを判断します。
OK or NG の判断は、CloudFormation の挙動などを踏まえたジャッジが必要なところであるため、慣れていないと難しいポイントだと思いました。

⭕️ 問題ないケース

例えば、下記は lambda の version についての差分です。
L2 で Lambda の version 発行を発行すると下記のように Lambda の version が差分として出ます。一見 lambda の version が置き換わってしまうように見えますが、これは問題ありません。むしろ lambda のコードに変更がある場合許容しないと変更が反映されません。

[-] AWS::Lambda::Version HelloLambdaVersion9J2f4O4DCJVum6gnWrAVCoPzhfy6ni4LsIijQrxUM orphan
[+] AWS::Lambda::Version HelloLambdaFunction/CurrentVersion HelloLambdaFunctionCurrentVersion91B723FF8012de14dd8aa9d66e9907188f189

[~] AWS::Lambda::Function HelloLambdaFunction HelloLambdaFunction
 └─ [~] Code
     ├─ [~] .S3Bucket:
     │   ├─ [+] Added: .Fn::Sub
     │   └─ [-] Removed: .Ref
     └─ [~] .S3Key:
         ├─ [-] serverless/sample-sls/dev/1757211030679-2025-09-07T02:10:30.679Z/hello.zip
         └─ [+] 95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df.zip

⚠️ 問題があるケース

例えば、下記は lambda の function 自体が置き換わってしまうケースです。

[-] AWS::Lambda::Function HelloLambdaFunction destroy
[+] AWS::Lambda::Function HelloLambdaFunction HelloLambdaFunction3DCA9067

先の修正で lamnda を L2 に書き換えましたが、L2 の論理 ID は下記のように CDK によって自動生成されてしまいます。
もしこのままデプロイすると、CloudFormation の仕様上、まず HelloLambdaFunction3DCA9067 の lambda が新規作成され、その後 HelloLambdaFunction が削除されます。
もしこのとき「lambda 関数自体の名前」が両者同じであれば、名前重複によりデプロイは失敗するでしょう。なので必ず論理IDが変わらないようにしましょう。

4. 既存のスタックに対してデプロイ

diff で差分も問題なければ、いよいよデプロイです。

デプロイするには予め実行しておく bootstrap コマンドが必要です。
AWS CDK は裏側で生成された CloudFormation をアップロードしたり、デプロイする際に IAM ロールをスイッチしたりを自動で しています。
その作業を CDK ができるようにするためにために必要なリソースを予め作成しておくのが bootstrap コマンドです。
下記のようにアカウントごとリージョンごとに実行します。

cdk bootstrap aws://<accountId>/<region>

bootstrap が完了したら、デプロイができます。
デプロイは下記のように deploy コマンドで行います。

cdk deploy <stack-name>

問題なければ無事デプロイが成功するはずです。

5. 動作確認

デプロイができたら、動作確認をしましょう。
例えば APIGW + Lambda であれば API 呼び出しをしてみるなどです。
エンドポイントが複数あれば、すべて確認するのが望ましいです。
自動テストなどがなければ、ここがひょっとしたら一番大変かもしれません。
プロジェクト内であらかじめ計画しておくとよいでしょう。

まとめ

以上が、serverless framework から AWS CDK に移行した際の大まかな流れと注意点です。

今回の移行であらためて、CloudFormation と AWS CDK の仕組みを裏側まで踏み込んで理解できたのは個人的に大きな収穫でした。

もし同様に AWS CDK への移行を検討されている方の参考になれば幸いです。

NCDCエンジニアブログ

Discussion