✂️

【SAMでCloudFormationインポート機能を使用】本番DBを稼働させたままサーバーレス三層アプリケーションのスタックを分割する

2024/03/31に公開

この記事で話すこと

SAMアプリケーションで同一テンプレートで定義された【APIGateway+Lambda+DynamoDB】を【APIGateway+Lambda】と【DynamoDB】にテンプレート(スタック)を分割する方法

きっかけ

  • 新APIデプロイにあたり、本番テーブルは常時稼働しており新テーブルへのデータ移行は避けたい。
  • 今後の運用面でもAPIとDBはスタックを分割しておいた方が、各層で互いに影響しないため変更に強い。

懸念点

既存リソースのインポートはCloudFormationの機能。SAM管理下でも実現できる?
結果としてはできました。 が、SAM CLIではCloudFormationの全ての機能を使えるわけではないので、本当にできるかな?と心配しました。)

先に結論(注意ポイント)

SAM CLIでデプロイしたテンプレートと実際のCloudFormation上のテンプレートには差分が発生していました。リソースのインポート時、インポートしたいリソース以外に差分が発生していると失敗します。
インポート作業時には その他リソースについて実際のCloudFormationテンプレートと手元のテンプレートを一致させる必要があり、SAM管理下においてはここが重要になります。

やること

現在の構成

AWS SAM🐿️でデプロイされたサーバーレス三層アプリケーションがあります。
フロントエンドはCloudFront+S3で静的サイトをホスティング、APIGateway+LambdaでAPIを構成し、DBにはDynamoDBというよくあるサーバーレス構成です。
リージョンの関係からフロントエンドは別のスタックで定義していますが、APIとDBは同一スタックで定義しています。

やりたいこと

新しいAPIをデプロイし、現在稼働中のDynamoDBテーブルはそのまま使いたい!
そのため既存のスタックの利用をやめて、各層ごとに分割された新しいスタックに置き換えます。
既存スタックは削除・新スタックでデプロイし直すという状況です。

課題

既存のスタックで管理されているDynamoDBテーブルのデータはどうなる?
既存スタックは使わなくなるが、削除するとテーブルも消える。
【要件をまとめると】本番稼働中のテーブルをそのまま別のスタックに移したい

解決策

①既存スタックのDynamoDBテーブルにDeletionPolicy属性をRetainにして更新

②新スタックを用意・↑のDynamoDBテーブルをインポートする

再現してみる

※フロントエンド(CloudFront+S3)は省略します。

現在の構成をデプロイ

Lambda関数はNode18, TypeScript, SDK v3を使用します。
検証としてDynamoDBテーブルからデータを取得する関数を作成します。
そして、 この時点ではリソースにDeletionPolicyは設定していないものとします。

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:
  Serverless Application for Deploy test
Resources:
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Events:
        MyApiEvent:
          Type: Api
          Properties:
            Path: /hello
            Method: get
      Environment:
        Variables:
          TABLE_NAME: !Ref MyTable
          TABLE_ARN: !GetAtt MyTable.Arn
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref MyTable
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: es2020
        Sourcemap: true
        EntryPoints:
          - app.ts
        External: [
          '@aws-sdk/client-dynamodb',
          ]
  MyTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: my-table
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: id
          KeyType: HASH
src/app.ts(Lambda関数コード)
import { DynamoDBClient, GetItemCommandInput, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

const dynamodbClient = new DynamoDBClient({
    region: 'ap-northeast-1',
});

const tableName = process.env.TABLE_NAME as string;

const params: GetItemCommandInput = {
    TableName: tableName,
    Key: {
        id: { S: '1' },
    },
};

export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    try {
        const command = new GetItemCommand(params);
        const { Item } = await dynamodbClient.send(command);
        return {
            statusCode: 200,
            body: JSON.stringify({
                result: Item,
            }),
        };
    } catch (err) {
        console.log(err);
        return {
            statusCode: 500,
            body: JSON.stringify({
                message: 'some error happened',
            }),
        };
    }
};

こちらでsam buildsam deploy --guidedでリソースをデプロイ。

DynamoDBテーブルにデータ作成・現在のAPIの動作確認

検証のため、DynamoDBには手動(CLI)でデータを作成します。

$ aws dynamodb put-item \
  --table-name my-table \
  --item '{"id": {"S": "1"}, "hello": {"S": "world"}}' \
  --region ap-northeast-1
  --return-consumed-capacity TOTAL

そして現在の構成でAPIにアクセスしてみます。

$ curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello
# 結果
{"result":{"id":{"S":"1"},"hello":{"S":"world"}}}

移行作業の実践

①DynamoDBにDeletionPolicy: Retainを設定

テンプレートに追記します。

# 前略
  MyTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Retain  # 追記!
    Properties:
      TableName: my-table
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: id
          KeyType: HASH

追記後、sam buildsam deploy --stack-name {スタック名} --region ap-northeast-1を実行します。

↑のように勘違いしてアレ??となりましたが、このままスタックを削除してみます。
※実際の本番環境においては環境を戻すことも考えて、その他リソースも含む元のスタックをいきなり削除はしませんが、今回は検証のためこの段階で削除してみます。

sam deleteで既存スタックを削除

$ sam delete
# 結果
Deleted successfully

APIは削除されましたが、DynamoDBテーブルは残っています。
【APIにアクセスしてみる】

$ curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello
curl: (6) Could not resolve host: xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com

【DynamoDBテーブルを確認】

③DynamoDB用のテンプレートを用意 - 1.スタック作成のためダミーリソースを作成

CloudFormationは既存スタックにインポートしたいリソースを定義することでインポートを行います。
ここで、新規作成と同時にリソースインポートはできないの?と思いました。
CloudFormation自体は新規スタック作成と同時にリソースインポートもできるようですが、今回はSAM管理下に置きたいので先にスタックを用意します。
既存リソースのインポートはCloudFormationの機能です。CloudFormationの拡張であるSAMの機能としては新規作成と同時にインポートはできないようでした。

というわけで、ダミーでS3バケットを定義したテンプレートを用意してスタックを作成します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:
  new-dynamo
  
Resources:
  DummyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: dummy-my-xxxxxxxxx-bucket

このテンプレートをsam buildsam deploy --guidedで初回デプロイ。

③DynamoDB用のテンプレートを用意 - 2.既存DynamoDBテーブルをインポート

ここでやることは二つあります。

  1. テンプレートにインポートしたい既存テーブルを定義
  2. インポート用のファイルを用意

【⚠️これは失敗パターン⚠️】
既存テーブルの定義は元のテンプレートからコピペ。
(ここで、DeletionPolicy: Retainは必ず記述されている必要があります。)
ダミー定義のS3バケットはまだこのまま。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  new-dynamo
  
Resources:
  DummyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: dummy-my-xxxxxxxxxx-bucket
  MyTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Retain
    Properties:
      TableName: my-table
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: id
          KeyType: HASH

そしてインポートについての設定を記述したimport.txtをテンプレートと同階層に作成。
参考:AWS CLI を使用した既存のリソースのスタックへのインポート

import.txt
[
    {
        "ResourceType": "AWS::DynamoDB::Table",
        "LogicalResourceId": "MyTable",
        "ResourceIdentifier": {
            "TableName": "my-table"
        }
    }
]

そして以下のようにAWS CLIで CloudFormation のコマンドを実行します。

$ aws cloudformation create-change-set \
    --stack-name {スタック名} --change-set-name ImportChangeSet \
    --change-set-type IMPORT \
    --resources-to-import file://import.txt \
    --template-body file://template.yaml \
    --region ap-northeast-1

変更セットをコンソールで確認してみます。

インポート中に他のリソースの更新・作成・削除を実行することはできないというエラー。
テンプレートに変更は加えていないのに!とかなり悩みました。
ここで、コンソール上からテンプレートを見に行くと・・・

手元のテンプレートにはない記述がありました。おそらく、SAMを使ってデプロイしたため実際のCloudFormation上ではこのようにMetadataが追記されているのだと思います。

【ここから成功パターン】
気を取り直して、テンプレート修正・変更セットを作成します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:
  new-dynamo
  
Resources:
  DummyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: dummy-my-xxxxxxxxx-bucket
    Metadata:  # 追記!
      SamResourceId: DummyBucket
  MyTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Retain
    Properties:
      TableName: my-table
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: id
          KeyType: HASH

再び変更セットをaws cloudformation create-change-setで作成すると…

インポートできそうです!
続けて、変更セットを適用します。

$ aws cloudformation execute-change-set --change-set-name ImportChangeSet --stack-name {スタック名} --region ap-northeast-1

インポートできました。

③DynamoDB用のテンプレートを用意 - 3. ダミーバケットの削除とDynamoDBテーブルの出力

続いて、不要なダミーバケットを削除・分割管理のためDynamoDBテーブルをOutputsセクションに定義して出力します。
(API側でテーブル名を指定するだけでも良いですが、分割管理をより整理して行うため+今回の検証の動作確認のためクロススタック参照で実装します)
新しいテンプレートはこちら。これでDynamoDBのテンプレートは完成です。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:
  new-dynamo
  
Resources:
  MyTable:
    Type: AWS::DynamoDB::Table
    DeletionPolicy: Retain
    Properties:
      TableName: my-table
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: id
          KeyType: HASH

Outputs:
  MyTableName:
    Value: !Ref MyTable
    Export:
      Name: !Sub "${AWS::StackName}-MyTableName"
  MyTableArn:
    Value: !GetAtt MyTable.Arn
    Export:
      Name: !Sub "${AWS::StackName}-MyTableArn"

sam buildsam deploy --stack-name {スタック名} --region ap-northeast-1で更新します。

新APIをデプロイしてテーブルデータをgetしてみる

稼働していたDynamoDBテーブルを新しいスタックにインポートできたので、新しいAPIから正しく接続できるか確認します。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description:
  new-api
  
Resources:
  MyFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
        - x86_64
      Events:
        MyApiEvent:
          Type: Api
          Properties:
            Path: /hello
            Method: get
      Environment:
        Variables:
          TABLE_NAME:
            Fn::ImportValue: !Sub "new-dynamo-MyTableName"
          TABLE_ARN:
            Fn::ImportValue: !Sub "new-dynamo-MyTableArn"
      Policies:
        - DynamoDBCrudPolicy:
            TableName:
              Fn::ImportValue: !Sub "new-dynamo-MyTableName"
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: es2020
        Sourcemap: true
        EntryPoints:
          - app.ts
        External: [
          '@aws-sdk/client-dynamodb',
          ]

Lambda関数のコードはそのまま変更なしです。
sam buildsam deploy --guidedで新APIをデプロイします。

  1. API実行
$ curl https://newxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello
# 結果
{"result":{"id":{"S":"1"},"hello":{"S":"world"}}}

同じ結果が返ってきました!

  1. Lambda関数の環境変数をコンソールで確認する。

    DynamoDBの情報が正しく反映されています。

気になるポイント

SimpleTableは使えない?

SAMアプリケーションなので、DynamoDB定義にAWS::Serverless::SimpleTableは使えないのでしょうか?
以下のテンプレートと、インポート設定ファイルで変更セットを作成してみます。

template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: 
  SimpleTable
  
Resources:
  DummyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: dummy-xxxxx-xxxx-bucket
  MyTable:
    Type: AWS::Serverless::SimpleTable
    DeletionPolicy: Retain
    Properties:
      PrimaryKey:
        Name: id
        Type: String
import.txt
[
    {
        "ResourceType": "AWS::Serverless::SimpleTable",
        "LogicalResourceId": "MyTable",
        "ResourceIdentifier": {
            "TableName": "my-table"
        }
    }
]

変更セット作成はできますが、エラーが発生します。

AWS::Serverless::SimpleTableリソースインポートに対応していないということで、SAMテンプレート構文ではCloudFormationリソースインポート機能は使用できません。

まとめ

SAMアプリケーションで同一テンプレートで定義された【APIGateway+Lambda+DynamoDB】を【APIGateway+Lambda】と【DynamoDB】にテンプレート(スタック)を分割する手順をまとめます。

  1. DeletionPolicy: Retainを指定して更新。元のスタックはここで削除してOK
  2. 新しいテンプレートを定義してスタックを作成。インポートと同時にはできないので、 S3バケット等のダミーリソースを仮に定義する。
  3. 新テンプレートにインポートしたい既存DynamoDBテーブルの内容を定義。インポート設定のためのテキストファイルを用意。
    ※ここで、ダミーリソースの定義がコンソール上の実際のCloudFormationテンプレートと差分がないようにする。
  4. 変更セット作成・実行によりインポートを実行
  5. ダミーリソースを削除、インポートしたDynamoDBテーブルに関する情報をOutputsセクションに定義する等、テンプレートを修正する
  6. 別途、APIを新テンプレートで定義してスタックを作成・デプロイする。

3、4の手順は基本的には公式ドキュメントの通りに行います。

SAMならではの挙動に注意すれば、CloudFormationの機能を利用して既存リソースをインポートすることが可能です。
今回はDynamoDBをAPIとはスタックを分割管理するという目的でしたが、その他のリソースにおいてもスタック分割や既存リソースのインポートは可能です。
IaCでのリソース管理は今回のような継続と変更の両立という点では複雑な面もありますが、可視化しながら一つ一つ丁寧に作業を進めていけるメリットは大きいので、このように小さく試して記事に残して慣れていけたらと思います!

GitHubで編集を提案
Fusic 技術ブログ

Discussion