Open5

AWS SAM で記述した Lambda Function を CDK に移行してみる

hassaku63hassaku63

SAM のテンプレートファイルはこちら。これをデプロイしておく。

Stack の名前は "sam-app" とする。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  sam-app

  Sample SAM Template for sam-app

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3
    # You can add LoggingConfig parameters such as the Logformat, Log Group, and SystemLogLevel or ApplicationLogLevel. Learn more here https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-function.html#sam-function-loggingconfig.
    LoggingConfig:
      LogFormat: JSON
Resources:
  TestFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.index
      Runtime: nodejs20.x
      Architectures:
      - x86_64
      AutoPublishAlias: live
      DeploymentPreference:
        Enabled: true
        Type: Canary10Percent10Minutes


Outputs:
  TestFunction:
    Value: !GetAtt TestFunction.Arn

CodeUri に指定した Function ハンドラはただ文字列結合をして返すだけの単純な実装にした。

// hello-world/app.ts
export async function index(event: Event): Promise<string> {
    return `Hello ${event.name}, ${event.message}, 2`;
};
hassaku63hassaku63

CDK migrate で取り込んでみる。

CDK のバージョンはこれを書いてる時点の最新である v.2.151.0 を使う。

$ npx cdk migrate  --from-stack --stack-name sam-app

取り込みは成功する。migrate して生成されたディレクトリはだいたい標準的な CDK プロジェクトレイアウトに従う形になっており、lib と bin でモジュールが分かれた構造になる。

出てくるコードはだいたい以下のような感じになる。Stack 定義をしている lib 以下のファイルだけ抜粋する。

一部、コンストラクタの引数の型が App になっていたり、プロパティがキャメルケースであるべきところがパスカルケースで出力されている箇所があったので、それを手直しした。

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as codedeploy from 'aws-cdk-lib/aws-codedeploy';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';

export interface SamAppStackProps extends cdk.StackProps {
}

/**
 * sam-app
 * Sample SAM Template for sam-app

 */
export class SamAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: SamAppStackProps = {}) {
    super(scope, id, props);

    // Resources
    const codeDeployServiceRole = new iam.CfnRole(this, 'CodeDeployServiceRole', {
      assumeRolePolicyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Action: [
              'sts:AssumeRole',
            ],
            Effect: 'Allow',
            Principal: {
              Service: [
                'codedeploy.amazonaws.com',
              ],
            },
          },
        ],
      },
      managedPolicyArns: [
        'arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForLambda',
      ],
    });

    const serverlessDeploymentApplication = new codedeploy.CfnApplication(this, 'ServerlessDeploymentApplication', {
      computePlatform: 'Lambda',
    });

    const testFunctionRole = new iam.CfnRole(this, 'TestFunctionRole', {
      assumeRolePolicyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Action: [
              'sts:AssumeRole',
            ],
            Effect: 'Allow',
            Principal: {
              Service: [
                'lambda.amazonaws.com',
              ],
            },
          },
        ],
      },
      managedPolicyArns: [
        'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
      ],
      tags: [
        {
          key: 'lambda:createdBy',
          value: 'SAM',
        },
      ],
    });

    const testFunction = new lambda.CfnFunction(this, 'TestFunction', {
      code: {
        s3Bucket: 'aws-sam-cli-managed-default-samclisourcebucket-qmxais1rzj6t',
        s3Key: 'sam-app/e09f83d64123090b2c2835b5e68f28b1',
      },
      handler: 'app.index',
      role: testFunctionRole.attrArn,
      runtime: 'nodejs20.x',
      timeout: 3,
      tags: [
        {
          key: 'lambda:createdBy',
          value: 'SAM',
        },
      ],
      architectures: [
        'x86_64',
      ],
      loggingConfig: {
        logFormat: 'JSON',
      },
    });
    testFunction.cfnOptions.metadata = {
      SamResourceId: 'TestFunction',
    };

    const testFunctionDeploymentGroup = new codedeploy.CfnDeploymentGroup(this, 'TestFunctionDeploymentGroup', {
      applicationName: serverlessDeploymentApplication.ref,
      autoRollbackConfiguration: {
        enabled: true,
        events: [
          'DEPLOYMENT_FAILURE',
          'DEPLOYMENT_STOP_ON_ALARM',
          'DEPLOYMENT_STOP_ON_REQUEST',
        ],
      },
      deploymentConfigName: `CodeDeployDefault.LambdaCanary10Percent10Minutes`,
      deploymentStyle: {
        deploymentType: 'BLUE_GREEN',
        deploymentOption: 'WITH_TRAFFIC_CONTROL',
      },
      serviceRoleArn: codeDeployServiceRole.attrArn,
    });

    const testFunctionVersionf649af05dd = new lambda.CfnVersion(this, 'TestFunctionVersionf649af05dd', {
      functionName: testFunction.ref,
    });
    testFunctionVersionf649af05dd.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;

    const testFunctionAliaslive = new lambda.CfnAlias(this, 'TestFunctionAliaslive', {
      name: 'live',
      functionName: testFunction.ref,
      functionVersion: testFunctionVersionf649af05dd.attrVersion,
    });
    testFunctionAliaslive.cfnOptions.updatePolicy = {
      codeDeployLambdaAliasUpdate: {
        applicationName: serverlessDeploymentApplication.ref,
        deploymentGroupName: testFunctionDeploymentGroup.ref,
      },
    };
    // Outputs
    new cdk.CfnOutput(this, 'CfnOutputTestFunction', {
      key: 'TestFunction',
      value: testFunction.attrArn,
    });
  }
}

これを CDK の方で更新デプロイできれば、とりあえず移行の最小限の対応は達成したと言える。

意図しない差分が出てないか、cdk diff で確認してみたが、あまり中身の詳しい差分情報は出てこなかった。

難しそうだったので、代えて cdk deploy の ChangeSet だけ作成する実行モードで比較してみることにした。cdk deploy の --method オプションに "prepare-change-set" を指定することで、実際のデプロイは実行せず ChangeSet の作成だけ行うことができる。

この実行結果を見てみると、どうやら差分は CDK が付与する Metadata の変更だけであることがわかった。

よって、CDK からの更新デプロイを行っても支障はないと判断してそのままデプロイを実行した。

hassaku63hassaku63

最低限の移行はこれでOKなので、問題なさそうなものをどんどん CDK ライクな記述に移行していきたい。

これは移行するシステムへの要求等々のプロジェクト特性にも依るだろうが、最悪再デプロイ(あるいは部分的な Replace)が許容できるものなら割と雑に扱える。とりあえず今回はその想定でいく。CloudFormation 的に Replace されたらめんどくさそうなものを個別で潰していけばよい。

とりあえず、デグレしないように内部実装をより CDK っぽくしていきたいので、まずは snapshot test を書くことにする。

// test/sam-app-snapshot.test.ts
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import * as SamApp from '../lib/sam-app-stack';

test('snapshot testing', () => {
  const app = new cdk.App();
    // WHEN
  const stack = new SamApp.SamAppStack(app, 'MyTestStack');
    // THEN
  const template = Template.fromStack(stack);

  expect(template.toJSON()).toMatchSnapshot();
});
$ npx jest
hassaku63hassaku63

ここまでの作業で、cdk diff や ChangeSet 以外にも snapshot というデグレチェックの強力な手段を得た。

hassaku63hassaku63

試しに、lambda function にアタッチしている IAM Role を L2 実装に置き換えてみる。簡単な bot 程度のアプリケーションならぶっちゃけ Relpace になってもなにも支障はないのだが、ひとまず論理ID はキープして Replace を防止するように努める。

TestFunctionRole という論理ID を持っているものを変更する。CDK のデフォルトは論理ID の末尾にランダムな英数字を追記する仕様なので、この論理ID をキープするためには override メソッドによるエスケープハッチを適用する必要がある。実際に Role 部分を書き直すと次のような感じになる。

    const role = new iam.Role(this, 'TestFunctionRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
    });
    role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'));
    (role.node.defaultChild! as iam.CfnRole).overrideLogicalId('TestFunctionRole');

型アサーションを使っているのが少々汚い感じがあるが、過渡期的なコードでしかないと思ったのでそのままにした。

問題なければ後日 Replace 上等で移植すればよいと思う。例えば、同じ権限を持った別の Role を L2 で横に作る → Role のアタッチを切り替え → 元の Role を消す、のように多段階でデプロイする、みたいな方法もとれる。

この変更によって生じる差分だが、次のようになる。まずは snapshot から

$ npx jest
 FAIL  test/sm-app-snapshot.test.ts
  ✕ snapshot testing (171 ms)

  ● snapshot testing

    expect(received).toMatchSnapshot()

    Snapshot name: `snapshot testing 1`

    - Snapshot  - 11
    + Received  + 12

    @@ -135,30 +135,31 @@
          "TestFunctionRole": {
            "Properties": {
              "AssumeRolePolicyDocument": {
                "Statement": [
                  {
    -               "Action": [
    -                 "sts:AssumeRole",
    -               ],
    +               "Action": "sts:AssumeRole",
                    "Effect": "Allow",
                    "Principal": {
    -                 "Service": [
    -                   "lambda.amazonaws.com",
    -                 ],
    +                 "Service": "lambda.amazonaws.com",
                    },
                  },
                ],
                "Version": "2012-10-17",
              },
              "ManagedPolicyArns": [
    -           "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
    +           {
    +             "Fn::Join": [
    +               "",
    +               [
    +                 "arn:",
    +                 {
    +                   "Ref": "AWS::Partition",
    +                 },
    +                 ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
    +               ],
                  ],
    -         "Tags": [
    -           {
    -             "Key": "lambda:createdBy",
    -             "Value": "SAM",
                },
              ],
            },
            "Type": "AWS::IAM::Role",
          },

      10 |   const template = Template.fromStack(stack);
      11 |
    > 12 |   expect(template.toJSON()).toMatchSnapshot();
         |                             ^
      13 | });
      14 |

      at Object.<anonymous> (test/sm-app-snapshot.test.ts:12:29)1 snapshot failed.
Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or run `npm run npx -- -u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        3.752 s, estimated 5 s
Ran all test suites.

見ての通りコケはするが、実質的な中身はかわっていないこともわかる。よって snapshot 的にはこの変更は決行しても問題なさそうだと評価できる。

次に cdk diff を見てみる。

$ npx cdk diff
Stack sam-app
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
IAM Policy Changes
┌───┬─────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource            │ Managed Policy ARN                                                             │
├───┼─────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ - │ ${TestFunctionRole} │ arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole               │
├───┼─────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${TestFunctionRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴─────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Resources
[~] AWS::IAM::Role TestFunctionRole TestFunctionRole 
 ├─ [~] AssumeRolePolicyDocument
 │   └─ [~] .Statement:
 │       └─ @@ -1,13 +1,9 @@
 │          [ ] [[ ]   {[-]     "Action": [[-]       "sts:AssumeRole"[-]     ],
 │          [+]     "Action": "sts:AssumeRole",
 │          [ ]     "Effect": "Allow",
 │          [ ]     "Principal": {[-]       "Service": [[-]         "lambda.amazonaws.com"[-]       ][+]       "Service": "lambda.amazonaws.com"[ ]     }[ ]   }[ ] ]
 └─ [-] Tags
     └─ [{"Key":"lambda:createdBy","Value":"SAM"}]


✨  Number of stacks with differences: 1

SAM がくっつけた Tag が剥がされているが、これは特に問題ない。それ以外の変更要素も概ね snapshot で確認したものと被っているので問題はなさそうに見える。

過剰な気もするが、deploy コマンドでも ChangeSet を確認してみる。

$ npx cdk deploy --method prepare-change-set     

✨  Synthesis time: 2.43s

sam-app:  start: Building 9d537be127ccc5cc229495f520e089e18b4da79ec87a35f1e3ebd0a59c46fa51:current_account-current_region
sam-app:  success: Built 9d537be127ccc5cc229495f520e089e18b4da79ec87a35f1e3ebd0a59c46fa51:current_account-current_region
sam-app:  start: Publishing 9d537be127ccc5cc229495f520e089e18b4da79ec87a35f1e3ebd0a59c46fa51:current_account-current_region
sam-app:  success: Published 9d537be127ccc5cc229495f520e089e18b4da79ec87a35f1e3ebd0a59c46fa51:current_account-current_region
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Policy Changes
┌───┬─────────────────────┬────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource            │ Managed Policy ARN                                                             │
├───┼─────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ - │ ${TestFunctionRole} │ arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole               │
├───┼─────────────────────┼────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${TestFunctionRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴─────────────────────┴────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
sam-app: deploying... [1/1]
sam-app: creating CloudFormation changeset...
Changeset arn:aws:cloudformation:ap-northeast-1:000011112222:changeSet/cdk-deploy-change-set/746a0a57-53df-47c3-bd6b-a83e3396683d created and waiting in review for manual execution (--no-execute)

 ✅  sam-app

✨  Deployment time: 10.93s

Outputs:
sam-app.TestFunction = arn:aws:lambda:ap-northeast-1:000011112222:function:sam-app-TestFunction-SUcKvF6YYdeq
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:00001112222![](https://storage.googleapis.com/zenn-user-upload/9cf2ea230d5c-20240807.png)
:stack/sam-app/0eb7bca0-541a-11ef-bffa-0ae20ade343f

✨  Total time: 13.36s

AWS::CDK::Metadata に変更が出ていたが、これはスルーしてOK。あとは AWS::IAM::Role に Replace ではない変更が出ていたが、その内容は次の通り

こちらも特に問題なさそうであることがわかる。よってそのままデプロイ。