CDK で作った CloudFormation テンプレートをプログラム内からデプロイする

AWS CDK (Cloud Development Kit) では通常 cdk deploy コマンドを使ってデプロイ処理を実施します。

これを cdk コマンドを使わずにプログラム内から実施できないか、以下の 2通りで試してみました。

  • (a) AWS CDK の API を利用
  • (b) AWS SDK の CloudFormation API を利用

なお、実際のところ (a) の場合でも、内部的には CloudFormation の API が呼び出されています。

今回のソースは http://github.com/fits/try_samples/tree/master/blog/20210418/

はじめに

今回は DynamoDB のテーブルを作るだけの単純なスタック(以下)を使用します。

a_sample/stack.ts
import { Construct, Stack, StackProps, CfnOutput } from '@aws-cdk/core'
import * as dynamodb from '@aws-cdk/aws-dynamodb'

export class SampleStack extends Stack {
    constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props)

        const table = new dynamodb.Table(this, 'Items', {
            partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }
        })

        new CfnOutput(this, 'TableName', {
            value: table.tableName
        })
    }
}

このスタックの CloudFormation テンプレートは、以下のような処理で文字列として取得できます。

a_sample/sample.ts
import { App } from '@aws-cdk/core'
import { SampleStack } from './stack'

const app = new App()
new SampleStack(app, 'SampleStack')
// CloudFormation テンプレートの文字列化
const tpl = JSON.stringify(app.synth().stacks[0].template)

console.log(tpl)
実行結果例
> cd a_sample

> npx ts-node sample.ts
{"Resources":{"Items5C12978B":{"Type":"AWS::DynamoDB::Table","Properties":{"KeySchema":[{"AttributeName":"id","KeyType":"HASH"}],"AttributeDefinitions":[{"AttributeName":"id","AttributeType":"S"}],"ProvisionedThroughput":{"ReadCapacityUnits":5,"WriteCapacityUnits":5}},"UpdateReplacePolicy":"Retain","DeletionPolicy":"Retain"}},"Outputs":{"TableName":{"Value":{"Ref":"Items5C12978B"}}}}

(a) AWS CDK の API を利用

cdk deploy コマンド処理内で使われている CloudFormationDeployments を直接使うのが簡単そうだったので、今回はこれを使います。

デフォルトでは cdk deploy 時と同じように進捗状況が出力されますが、これは quiettrue にする事で抑制できました。

a_sample/deploy_a.ts
import { App } from '@aws-cdk/core'
import { SdkProvider } from 'aws-cdk/lib/api/aws-auth'
import { CloudFormationDeployments } from 'aws-cdk/lib/api/cloudformation-deployments'

import { SampleStack } from './stack'

const app = new App()
new SampleStack(app, 'Sample1Stack')

const run = async () => {
    const deployer = new CloudFormationDeployments({
        sdkProvider: await SdkProvider.withAwsCliCompatibleDefaults()
    })
    // デプロイ処理
    const r = await deployer.deployStack({
        stack: app.synth().stacks[0],
        quiet: true
    })

    console.log(r)
}

run().catch(err => console.error(err))
パッケージインストール例
> cd a_sample
> npm install -D ts-node typescript @types/node
・・・
> npm install @aws-cdk/aws-dynamodb aws-cdk
・・・

実行結果は以下の通りです。 CloudFormation のスタックが登録され、DynamoDB のテーブルが問題なく作成されました。

実行結果
> npx ts-node deploy_a.ts

Sample1Stack: creating CloudFormation changeset...
{
  noOp: false,
  outputs: { TableName: 'Sample1Stack-Items・・・' },
  ・・・
  stackArtifact: CloudFormationStackArtifact {
    assembly: CloudAssembly {
      ・・・
    },
    id: 'Sample1Stack',
    manifest: {
      type: 'aws:cloudformation:stack',
      environment: 'aws://unknown-account/unknown-region',
      properties: [Object],
      metadata: [Object]
    },
    messages: [],
    _dependencyIDs: [],
    environment: {
      account: 'unknown-account',
      region: 'unknown-region',
      name: 'aws://unknown-account/unknown-region'
    },
    templateFile: 'Sample1Stack.template.json',
    parameters: {},
    tags: {},
    assumeRoleArn: undefined,
    cloudFormationExecutionRoleArn: undefined,
    stackTemplateAssetObjectUrl: undefined,
    requiresBootstrapStackVersion: undefined,
    bootstrapStackVersionSsmParameter: undefined,
    terminationProtection: undefined,
    stackName: 'Sample1Stack',
    assets: [],
    displayName: 'Sample1Stack',
    name: 'Sample1Stack',
    originalName: 'Sample1Stack',
    _deps: [],
    _template: { Resources: [Object], Outputs: [Object] }
  }
}

(b) AWS SDK の CloudFormation API を利用

今回は、AWS SDK for JavaScript v3 を使います。

実質的に AWS CLIcloudformation deploy コマンドと同じ事をするだけですが、AWS SDK にはこれに相当する API が用意されていないようなので、下記ソースコードを参考に自作してみました。

なお、CDK の下記ソースコードでも同じような処理になっていました。

簡単にまとめると、以下のような処理を実装する事になります。

  • (1) DescribeStacks で Stack の有無を確認して、ChangeSetType(CREATEUPDATE)を決定
  • (2) CreateChangeSet で変更セットを作成
  • (3) 処理が完了するまで DescribeChangeSet をポーリング
  • (4) ExecuteChangeSet で変更セットを実行
  • (5) 処理が完了するまで DescribeStacks をポーリング

(1) の DescribeStacks では該当するスタックが存在しない場合にもエラーとなってしまうため、他のエラーと区別するためにエラーメッセージが指定の文字列に合致するかどうかで判定しています。(AWS CLI や CDK と同様)

(3) では変更セットのステータスが CREATE_PENDINGCREATE_IN_PROGRESS 以外になるまでポーリングし、(5) ではスタックのステータスが xxx_IN_PROGRESS 以外になるまでポーリングすれば良さそうです。

また、(3) におけるポーリングの間隔は AWS CLI と CDK 共に 5秒毎となっていましたが、(5) の方は AWS CLI が 30秒で CDK は 5秒となっているようでした。

b_sample/deployer.ts(CloudFormation の API を使ったデプロイ処理の実装)
import { CloudFormationStackArtifact } from '@aws-cdk/cx-api'

import { 
    CloudFormationClient, 
    CreateChangeSetCommand, ExecuteChangeSetCommand, 
    DescribeChangeSetCommand, DescribeStacksCommand,
    Change, Stack
} from '@aws-sdk/client-cloudformation'

const MAX_RETRY = 100
const WAIT_TIME_CREATE = 5000
const WAIT_TIME_EXECUTE = 10000

const sleep = (ms: number) =>
    new Promise(resolve => setTimeout(resolve, ms))

const cf = new CloudFormationClient({})

type ChangeSetType = 'CREATE' | 'UPDATE'

export class CfDeployer {
    static async deploy(stackArtifact: CloudFormationStackArtifact): Promise<Stack | undefined> {
        const stackName = stackArtifact.stackName

        // (1)
        const changeSetType = await CfDeployer.selectChangeSetType(stackName)
        // (2)
        const r1 = await cf.send(new CreateChangeSetCommand({
            ChangeSetName: `ChangeSet-${Date.now()}`,
            StackName: stackName,
            TemplateBody: JSON.stringify(stackArtifact.template),
            ChangeSetType: changeSetType
        }))

        console.log(r1)

        const changeSetId = r1.Id

        if (!changeSetId) {
            throw new Error(`failed create ChangeSet: StackName=${stackName}`)
        }

        // (3)
        const cs = await CfDeployer.waitForCreate(changeSetId, stackName)

        if (cs.length < 1) {
            console.log('*** NO CHANGE')
            return CfDeployer.getStack(stackName)
        }
        // (4)
        const r2 = await cf.send(new ExecuteChangeSetCommand({
            ChangeSetName: changeSetId,
            StackName: stackName
        }))

        console.log(r2)
        // (5)
        return CfDeployer.waitForExecute(stackName)
    }

    static async getStackStatus(stackName: string): Promise<string> {
        const stack = await CfDeployer.getStack(stackName)
        return stack?.StackStatus ?? 'NONE'
    }

    static async getStack(stackName: string): Promise<Stack | undefined> {
        try {
            const r = await cf.send(new DescribeStacksCommand({
                StackName: stackName
            }))

            return r.Stacks?.[0]
        } catch(e) {
            // スタックが存在しなかった場合のエラー以外はそのまま throw
            if (e.Code !== 'ValidationError' || 
                e.message !== `Stack with id ${stackName} does not exist`) {
                throw e
            }
        }

        return undefined
    }
    // (3)
    private static async waitForCreate(changeSetId: string, stackName: string): Promise<Change[]> {

        for (let i = 0; i < MAX_RETRY; i++) {
            await sleep(WAIT_TIME_CREATE)

            const r = await cf.send(new DescribeChangeSetCommand({
                ChangeSetName: changeSetId,
                StackName: stackName
            }))

            if (r.Status === 'CREATE_PENDING' || 
                r.Status === 'CREATE_IN_PROGRESS') {

                continue
            }

            if (r.Status == 'CREATE_COMPLETE') {
                return r.Changes ?? []
            }

            if (r.Status == 'FAILED') {
                console.log(`*** failed: ${JSON.stringify(r)}`)
                return []
            }

            throw new Error(`failed create ChangeSet: ChangeSetId=${changeSetId}, StackName=${stackName}`)
        }

        throw new Error(`create ChangeSet timeout: ChangeSetId=${changeSetId}, StackName=${stackName}`)
    }
    // (5)
    private static async waitForExecute(stackName: string): Promise<Stack> {
        for (let i = 0; i < MAX_RETRY; i++) {
            await sleep(WAIT_TIME_EXECUTE)

            const stack = await CfDeployer.getStack(stackName)

            if (!stack) {
                throw new Error('not found')
            }

            if (!stack.StackStatus?.endsWith('_IN_PROGRESS')) {
                return stack
            }
        }

        throw new Error(`execute ChangeSet timeout: StackName=${stackName}`)
    }

    private static async selectChangeSetType(stackName: string): Promise<ChangeSetType> {

        const status = await CfDeployer.getStackStatus(stackName)

        return (status == 'NONE' || status == 'REVIEW_IN_PROGRESS') ? 
            'CREATE' : 'UPDATE'
    }
}

上記を使ってスタックをデプロイする処理は以下のようになります。

b_sample/deploy_b.ts
import { App } from '@aws-cdk/core'

import { CfDeployer } from './deployer'
import { SampleStack } from './stack'

const app = new App()
new SampleStack(app, 'Sample2Stack')

CfDeployer.deploy(app.synth().stacks[0])
    .then(r => console.log(r))
    .catch(err => console.error(err))
パッケージインストール例
> cd b_sample
> npm install -D ts-node typescript @types/node
・・・
> npm install @aws-cdk/aws-dynamodb @aws-sdk/client-cloudformation
・・・

実行結果は以下の通りです。 こちらも CloudFormation のスタックが登録され、DynamoDB のテーブルが問題なく作成されました。

実行結果
> npx ts-node deploy_b.ts

{
  '$metadata': {
    httpStatusCode: 200,
    requestId: '・・・',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  Id: 'arn:aws:cloudformation:・・・:changeSet/ChangeSet-・・・',
  StackId: 'arn:aws:cloudformation:・・・:stack/Sample2Stack/・・・'
}
{
  '$metadata': {
    httpStatusCode: 200,
    requestId: '・・・',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  }
}
{
  StackId: 'arn:aws:cloudformation:・・・',
  StackName: 'Sample2Stack',
  ChangeSetId: 'arn:aws:cloudformation:・・・',
  Description: undefined,
  Parameters: undefined,
  ・・・
  StackStatus: 'CREATE_COMPLETE',
  StackStatusReason: undefined,
  DisableRollback: false,
  NotificationARNs: [],
  TimeoutInMinutes: undefined,
  Capabilities: undefined,
  Outputs: [
    {
      OutputKey: 'TableName',
      OutputValue: 'Sample2Stack-Items・・・',
      Description: undefined,
      ExportName: undefined
    }
  ],
  RoleARN: undefined,
  Tags: [],
  EnableTerminationProtection: false,
  ParentId: undefined,
  RootId: undefined,
  DriftInformation: { StackDriftStatus: 'NOT_CHECKED', LastCheckTimestamp: undefined }
}