CDKへの段階移行に使えるかも?CDKからSAMテンプレートを読み込んでリソースを追加作成してみた

CX事業本部の岩田です。

CDKすごく便利そうですよね??便利そうなので、この際インフラの管理は全てCDKに移行して...と言いたいところですが、既にCloudFormationやSAMで諸々の環境が構築済みで移行コストやリスクを懸念してCDKの利用に踏み切れない といったケースは多いと思います。そういったケースに対応するため、比較的移行コストとリスクを下げつつSAMとCDKを共存させる方法について調べてみました。

環境等

  • OS : macOS Mojave 10.14.6
  • Node.js : v10.15.1
  • AWS CDK : 1.4.0 (build 175471f)

やること

元々SAMでリソースを管理しているサーバーレスアプリケーションがあります。 現在SAMで管理しているリソースの中で記述が冗長になりがちな部分をCDKに切り出します。 現状デプロイはにSAMを利用しているので、CDK導入後もデプロイは継続してSAMを利用することとします。 同様にSAM CLIを使ってローカルテストが実施できることも必須要件です。

現行の構成

こんなディレクトリ構成です。docs/swagger.ymlでAPIの仕様を定義しており、SAMテンプレートからも参照しています。

├── docs
│   └── swagger.yml
├── sam.yml
└── src
    ├── create_pet.py
    ├── get_pet.py
    └── list_pets.py

現行のSAMテンプレート

現行のSAMテンプレートです。

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: 'CDK With SAM'
Globals:
  Function:
    Runtime: python3.7
Resources:
  ListPets:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: src
      Handler: list_pets.handler
      Role: !GetAtt LambdaExecuteRole.Arn
  GetPet:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: src    
      Handler: get_pet.handler
      Role: !GetAtt LambdaExecuteRole.Arn
  CreatePet:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: src    
      Handler: create_pet.handler
      Role: !GetAtt LambdaExecuteRole.Arn
  PetStoreApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prd
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: docs/swagger.yml
  LambdaExecuteRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - 'lambda.amazonaws.com'
          Action: sts:AssumeRole
      Policies:
      -
        PolicyName: 'lambda-permissions'
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            -
              Effect: "Allow"
              Action:
                - "logs:CreateLogGroup"
                - "logs:CreateLogStream"
                - "logs:PutLogEvents"
              Resource:
                - "*"
  LambdaPermissionListPets:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref ListPets
      Principal: apigateway.amazonaws.com
  LambdaPermissionGetPet:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref GetPet
      Principal: apigateway.amazonaws.com
  LambdaPermissionCreatePet:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref CreatePet
      Principal: apigateway.amazonaws.com
  ListPetsLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${ListPets}
      RetentionInDays: 731
  GetPetLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${GetPet}
      RetentionInDays: 731
  CreatePetLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${CreatePet}
      RetentionInDays: 731 

Lambdaを定義する部分はまあ良いのですが、Cloudwatch Logsのロググループの定義とAPI GwからLambdaを呼び出すためのパーミッションの定義をLambdaの数だけ繰り返しており(ハイライト箇所)非常に冗長です。こういったループ処理はプログラムにやらせたいですね。 とりあえず検証用にデプロイしておきます。

sam package --template-file sam.yml --s3-bucket <適当なS3バケット> --output-template-file output.yml
sam deploy --template-file output.yml --stack-name sam-with-cdk --capabilities CAPABILITY_IAM

CDKと共存させてみる

早速CDKと共存させてみましょう。cdkというディレクトリを作って、ここにCDK関連の諸々を突っ込んでいきます。

mkdir cdk
cd cdk
cdk init app --language=typescript

ディレクトリ構成はこんな感じに変わります

├── cdk
│   ├── README.md
│   ├── bin
│   │   └── cdk.ts
│   ├── cdk.json
│   ├── cdk.out
│   │   ├── CdkStack.template.json
│   │   ├── cdk.out
│   │   └── manifest.json
│   ├── lib
│   │   └── cdk-stack.ts
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json
│   └── tsconfig.json
├── docs
│   └── swagger.yml
├── output.yml
├── sam.yml
├── output.yml          # sam packageでパッケージ後のテンプレート
└── src
    ├── create_pet.py
    ├── get_pet.py
    └── list_pets.py

CDKのコードを書く!!

準備ができたのでCDKのコードを書いていきます。 今回は既存のSAMテンプレートから

  • AWS::Lambda::Permissionのリソース
  • AWS::Logs::LogGroupのリソース

の定義を削除し、CDKで生成することを目標とします。

まずはCDKから既存のSAMテンプレートを読み込みます。CDKにはCfnIncludeというクラスが用意されているので、このクラスのコンストラクタにSAMテンプレートを渡してインスタンスを生成します。なお、ここで指定するテンプレートはsam packageでパッケージ後のテンプレートです。

 const sam_output_path = path.resolve(__dirname, '../../output.yml');
 const cfn_template = <any> new cdk.CfnInclude(this, "PackagedSamTemplate", {
   template: yaml.parse(fs.readFileSync(sam_output_path).toString())
 });

続いて読み込んだテンプレートからAWS::Serverless::Functionのリソースを抽出します

const resources =  cfn_template.template.Resources;
const funcs = Object.keys(resources).filter(key => {
    return resources[key].Type === 'AWS::Serverless::Function';
});

これでfuncsの中にリソースのキー一覧が入ります。 今回のテンプレートだと、funcsの中身は['ListPets', 'GetPet', 'CreatePet']になります。

リソースのキーが全て取得できたので、ループしながらロググループの作成とパーミッションの設定を行います。 logGroupNamefunctionNameはSAMテンプレートで定義されたLambdaのFunctionNameを動的に利用するため、Fnクラスのスタティックメソッドsubを利用します。このメソッドがCloudFormationのFn::Sub相当の動きになります。

funcs.forEach(func_key => {
  new LogGroup(this, `${func_key}LogGroup`, {
    retention: RetentionDays.TWO_YEARS,
    logGroupName: Fn.sub(`/aws/lambda/\${${func_key}}`)
  });

  new CfnPermission(this, `LamberPermission${func_key}`, {
    principal: 'apigateway.amazonaws.com',
    action: 'lambda:InvokeFunction',
    functionName: Fn.sub(`\${${func_key}}`)
  });
});

最終形

最終的なCDKのコードです。

import * as cdk from '@aws-cdk/core';
import {LogGroup, RetentionDays} from '@aws-cdk/aws-logs';
import *  as fs from 'fs';
import *  as yaml from 'yaml';
import *  as path from 'path';
import { Fn } from '@aws-cdk/core';
import { CfnPermission} from '@aws-cdk/aws-lambda';


export class CdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here
    // Package後のSAMテンプレートを読み込み
    const sam_output_path = path.resolve(__dirname, '../../output.yml');
    const cfn_template = <any> new cdk.CfnInclude(this, "PackagedSamTemplate", {
      template: yaml.parse(fs.readFileSync(sam_output_path).toString())
    });

    // SAMテンプレートからTypeがAWS::Serverless::Functionのリソースを抽出
    const resources =  cfn_template.template.Resources;
    const funcs = Object.keys(resources).filter(key => {
        return resources[key].Type === 'AWS::Serverless::Function';
    });

    funcs.forEach(func_key => {
      // LogGroupを作成する
      new LogGroup(this, `${func_key}LogGroup`, {
        retention: RetentionDays.TWO_YEARS,
        logGroupName: Fn.sub(`/aws/lambda/\${${func_key}}`)
      });
      // ApiGWからLambdaをInvokeするためのパーミッションを設定する
      new CfnPermission(this, `LamberPermission${func_key}`, {
        principal: 'apigateway.amazonaws.com',
        action: 'lambda:InvokeFunction',
        functionName: Fn.sub(`\${${func_key}}`)
      });
    });

  }
}

CDKに移植できたのでSAMテンプレートからは

  • AWS::Lambda::Permissionのリソース
  • AWS::Logs::LogGroupのリソース

の記述を削除します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: 'CDK With SAM'
Globals:
  Function:
    Runtime: python3.7
Resources:
  ListPets:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: src
      Handler: list_pets.handler
      Role: !GetAtt LambdaExecuteRole.Arn
  GetPet:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: src    
      Handler: get_pet.handler
      Role: !GetAtt LambdaExecuteRole.Arn
  CreatePet:
    Type: 'AWS::Serverless::Function'
    Properties:
      CodeUri: src    
      Handler: create_pet.handler
      Role: !GetAtt LambdaExecuteRole.Arn
  PetStoreApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prd
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: docs/swagger.yml
  LambdaExecuteRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service:
            - 'lambda.amazonaws.com'
          Action: sts:AssumeRole
      Policies:
      -
        PolicyName: 'lambda-permissions'
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            -
              Effect: "Allow"
              Action:
                - "logs:CreateLogGroup"
                - "logs:CreateLogStream"
                - "logs:PutLogEvents"
              Resource:
                - "*"

SAM & CDKでデプロイしてみる!

準備ができたのでSAMとCDKを組み合わせてデプロイしてみます。 一点注意したいのですが、一気にSAM & CDKでのデプロイに移行すると、存在するロググループを作成しにいってエラーになってしまいます。SAM & CDKの構成に移行するため、一旦現在のSAMテンプレートでデプロイを行いロググループを削除します。 もし稼働中のシステムをSAM & CDKの構成に移行する場合は要注意です!!

sam package --template-file sam.yml --s3-bucket <適当なS3バケット> --output-template-file output.yml
sam deploy --template-file output.yml --stack-name sam-with-cdk --capabilities CAPABILITY_IAM

これで準備ができたので改めてSAM & CDKでデプロイを行います。cdk synthというコマンドを実行することでCDKからCloudFormationのテンプレートが出力できるので

  • sam package
  • cdk synth
  • sam deploy

と順番に実行し、デプロイを行います。

sam package --template-file sam.yml --s3-bucket <適当なS3バケット> --output-template-file output.yml
cd cdk
cdk synth > cdk_output.yml
sam deploy --template-file cdk_output.yml --stack-name sam-with-cdk --capabilities CAPABILITY_IAM

マネコンから確認してみましょう

マネコンから確認したCloudFormationの出力

CDKで追加したリソースもバッチリ作成できていますね!

ちなみにcdk synthの出力はこんな感じでした

Description: CDK With SAM
Transform: AWS::Serverless-2016-10-31
AWSTemplateFormatVersion: "2010-09-09"
Globals:
  Function:
    Runtime: python3.7
Resources:
  ListPets:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: s3://some-s3bucket/21a80cbe04a4a456dc36ec1643dfcb20
      Handler: list_pets.handler
      Role:
        Fn::GetAtt:
          - LambdaExecuteRole
          - Arn
  GetPet:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: s3://some-s3bucket/21a80cbe04a4a456dc36ec1643dfcb20
      Handler: get_pet.handler
      Role:
        Fn::GetAtt:
          - LambdaExecuteRole
          - Arn
  CreatePet:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: s3://some-s3bucket/21a80cbe04a4a456dc36ec1643dfcb20
      Handler: create_pet.handler
      Role:
        Fn::GetAtt:
          - LambdaExecuteRole
          - Arn
  PetStoreApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prd
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: s3://some-s3bucket/fa270f10518978fcd487a80e0c9c0f0d
  LambdaExecuteRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: lambda-permissions
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  - "*"
  ListPetsLogGroup297C159C:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName:
        Fn::Sub: /aws/lambda/${ListPets}
      RetentionInDays: 731
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
    Metadata:
      aws:cdk:path: CdkStack/ListPetsLogGroup/Resource
  LamberPermissionListPets:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName:
        Fn::Sub: ${ListPets}
      Principal: apigateway.amazonaws.com
    Metadata:
      aws:cdk:path: CdkStack/LamberPermissionListPets
  GetPetLogGroupD6EA9D77:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName:
        Fn::Sub: /aws/lambda/${GetPet}
      RetentionInDays: 731
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
    Metadata:
      aws:cdk:path: CdkStack/GetPetLogGroup/Resource
  LamberPermissionGetPet:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName:
        Fn::Sub: ${GetPet}
      Principal: apigateway.amazonaws.com
    Metadata:
      aws:cdk:path: CdkStack/LamberPermissionGetPet
  CreatePetLogGroup292AC3D6:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName:
        Fn::Sub: /aws/lambda/${CreatePet}
      RetentionInDays: 731
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
    Metadata:
      aws:cdk:path: CdkStack/CreatePetLogGroup/Resource
  LamberPermissionCreatePet:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName:
        Fn::Sub: ${CreatePet}
      Principal: apigateway.amazonaws.com
    Metadata:
      aws:cdk:path: CdkStack/LamberPermissionCreatePet
  CDKMetadata:
    Type: AWS::CDK::Metadata
    Properties:
      Modules: aws-cdk=1.4.0,@aws-cdk/assets=1.4.0,@aws-cdk/aws-cloudwatch=1.4.0,@aws-cdk/aws-ec2=1.4.0,@aws-cdk/aws-events=1.4.0,@aws-cdk/aws-iam=1.4.0,@aws-cdk/aws-kms=1.4.0,@aws-cdk/aws-lambda=1.4.0,@aws-cdk/aws-logs=1.4.0,@aws-cdk/aws-s3=1.4.0,@aws-cdk/aws-s3-assets=1.4.0,@aws-cdk/aws-sqs=1.4.0,@aws-cdk/aws-ssm=1.4.0,@aws-cdk/core=1.4.0,@aws-cdk/cx-api=1.4.0,@aws-cdk/region-info=1.4.0,jsii-runtime=node.js/v10.15.1

読み込んだSAMテンプレートに色々追加されているのが分かると思います

まとめ

今回紹介したような内容であればCloudFormationのマクロを作ってしまうのもありだと思いますが、マクロに比べてCDKの方が環境準備が簡単そうな感触でした。 既存資産のことを考えると、ドラスティックにCDKへ移行するというのは難しいケースもあるかと思いますが、こういったテクニックを組み合わせつつ改善していきたいですね。 CfnIncludeで読み込んだテンプレートをCDK側で更新したい!!というissueも上がっているので、こちらの動向にも注目です。

https://github.com/aws/aws-cdk/issues/3537