Amazon Web Services ブログ

CDK Watch を活用した開発速度の向上

本稿は、2021 年 12 月 2 日に AWS Developer Tools Blog で公開された “Increasing development speed with CDK Watch” を翻訳したものです。

AWS Cloud Development Kit (CDK) の CLI に導入されている操作モード cdk watch、および cdk deploy のフラグ --hotswap--no-rollback を紹介します。 cdk watch はコードとアセットの変更を監視し、ファイル変更が検出されるたびに最適な形式のデプロイを自動的に実行することで、開発を効率化できます。これにより、CDK アプリケーションに変更を加えるたびに cdk deploy を実行する必要がなくなります。cdk watch では --hotswap フラグが使用できる変更の場合は使用され、AWS CloudFormation でのフルデプロイを行わずにインプレースで更新されます。AWS Lambda ハンドラーコード、Amazon ECS コンテナイメージ、AWS Step Functions ステートマシンなどの CDK アセットでは、CDK CLI が各 AWS サービスの API を使用して直接更新します。それ以外のアセットでは、CloudFormation のフルデプロイが実行されます。また、--no-rollback フラグを使用することで CloudFormation の更新失敗時にロールバックが行われないようになるため、デプロイ失敗時に再実行するまでの時間を短縮できます。

以下の手順を実行することで、cdk watch および --hotswap--no-rollback フラグの動きを確認できます。この記事では TypeScript で CDK を使用しますが、watch は CDK でサポートされているすべての言語で機能します。最初に空の CDK アプリケーションを作成し、TypeScript と Express を使用したシンプルなコンテナアプリケーションを追加します。次に、アプリケーションをデプロイするために必要なインフラストラクチャを作成する CDK スタックを記述します。最後に、cdk watch を使用してアプリケーションコードに繰り返し変更を加えていきます。

前提条件

  • AWS アカウントを持っていること
  • ローカルに CDK がインストールされていること

セットアップ

CDK CLI V2 がインストールされていることを確認してください (cdk watch は V1 でも動作しますが、この記事ではすべて V2 を使用しています)。まだインストールしていない場合は、AWS CDK 開発者ガイドの手順を参照してインストールしてください。インストールが正しく行われたことを確認するには、ターミナルで cdk --version コマンドを実行します。次のように出力されれば問題ありません。
※ 本ブログ記事では、CDK CLI バージョン 2.141.0 で動作確認しています。他のバージョンでは挙動が異なる可能性があるので、ご注意ください。

cdk --version
2.X.X (build XXXXXXX)

最初に、ターミナルで次のコマンドを実行し、TypeScript の CDK アプリケーションを作成します。

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

アプリケーションコード

cdk-watch ディレクトリ上で、Docker イメージをビルドするために必要なディレクトリとファイルを作成します。

mkdir docker-app

次に、アプリケーションの依存関係を宣言する package.json を作成する必要があります。記載する必要がある依存関係は Express のみです。TypeScript はアプリケーションデプロイ前に JavaScript にコンパイルされるため、依存関係として宣言する必要はありません。docker-app/package.json のファイルを作成し、次の内容を追加してください。

{
     "name": "simple-webpage",
     "version": "1.0.0",
     "description": "Demo web app running on Amazon ECS",
     "license": "MIT-0",
     "dependencies": {
          "express": "^4.17.1"
     },
     "devDependencies": {
          "@types/express": "^4.17.13"
     }
}

次に、ウェブページとして表示させるための HTML ファイルを作成する必要があります。docker-app/index.html のファイルを作成し、次の内容を追加してください。

<!DOCTYPE html>

<html lang="en" dir="ltr">
<head>
     <meta charset="utf-8">
     <title>Simple Webpage </title>
</head>

<body>
<div align="center">
     <h2>Hello World</h2>
     <hr width="25%">
</div>
</body>
</html>

作成した HTML ファイルが、サイトにアクセスした際に表示されるようにするための Express コードを作成します。 docker-app/webpage.ts のファイルを作成し、次のコードを追加してください。

import * as express from 'express';

const app = express();

app.get("/", (req, res) => {
     res.sendFile(__dirname + "/index.html");
});

app.listen(80, function () {
     console.log("server started on port 80");
});

最後に、アプリケーションを起動する Dockerfile を作成します。docker-app/Dockerfile のファイルを作成し、次のコードを追加してください。

FROM node:alpine
RUN mkdir -p /usr/src/www
WORKDIR /usr/src/www
COPY . .
RUN npm install --production-only
CMD ["node", "webpage.js"]

インフラストラクチャコード

次に、Web ページをホストするインフラストラクチャを定義する CDK スタックを作成します。aws_ecs_patterns モジュールの ApplicationLoadBalancedFargateService コンストラクトを使用することで、スタックを大幅に単純化できます。lib/cdk-watch-stack.ts を次の例のように修正してください。

import { 
    Stack,
    StackProps,
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_ecs_patterns as ecs_patterns,
} from 'aws-cdk-lib';

import { Construct } from 'constructs';

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

        const vpc = new ec2.Vpc(this, 'Vpc', {
            maxAzs: 2,
            natGateways: 1,
        });

        new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'EcsService', {
            vpc,
            taskImageOptions: {
                image: ecs.ContainerImage.fromAsset('docker-app'),
                containerPort: 80,
            },
        });
    }
}

cdk.json の build キーで指定されたコマンドは、cdk watch 実行時を含むすべてのデプロイ時に、synthesis ステップの前に実行されます。今回作成した TypeScript アプリケーションは JavaScript にコンパイルする必要があるので、cdk.json"app" キーと同じ階層に以下のコードを追加してください。

"build": "cd docker-app && npm install && tsc",

これにより、完全にサーバーレスな Docker アプリケーションが作成されます。これらの変更を行ったら、次のコマンドを実行してください。

yarn install # お好みで "npm install" をお使いください
cdk deploy

デプロイが完了すると、以下のように出力されるはずです。

Bash
✅ CdkWatchStack

Outputs:
CdkWatchStack.EcsServiceLoadBalancerDNS6D595ACE = CdkWa-EcsSe-18QPSCKV5G8XP-xxxxxxxxxx.us-east-2.elb.amazonaws.com
CdkWatchStack.EcsServiceServiceURLE56F060F = http://CdkWa-EcsSe-18QPSCKV5G8XP-xxxxxxxxxx.us-east-2.elb.amazonaws.com

Stack ARN:
arn:aws:cloudformation:us-east-2:xxxxxxxxxxxx:stack/CdkWatchStack/1b15db20-428a-11ec-b96f-xxxxxxxxxxxx

Outputs セクションの 2 行目に記載されているリンクを開いてください。「Hello World」と書かれたページが表示されるはずです。

アプリケーションコードの変更

アプリケーションをデプロイしたら、cdk watch を使用して変更を加えていくことができます。ターミナルで cdk watch を実行すると、以下のような出力が表示されます。

'watch' is observing directory '' for changes
'watch' is observing the file 'cdk.context.json' for changes
'watch' is observing directory 'bin' for changes
'watch' is observing directory 'docker-app' for changes
'watch' is observing directory 'lib' for changes
'watch' is observing the file 'bin/cdk-watch.ts' for changes
'watch' is observing the file 'lib/cdk-watch-stack.ts' for changes
'watch' is observing the file 'docker-app/Dockerfile' for changes
'watch' is observing the file 'docker-app/index.html' for changes
'watch' is observing the file 'docker-app/package.json' for changes
'watch' is observing the file 'docker-app/webpage.ts' for changes

アプリケーションコードを変更する場合、cdk watch を使用することでデプロイを高速化できます。以下の変更を index.html に加え、動作を確認してみましょう。

<!DOCTYPE html>

<html lang="en" dir="ltr">
<head>
     <meta charset="utf-8">
     <title> Simple Webpage </title>
</head>

<body>
<div align="center">
     <h2>Hello World</h2>
     <hr width="25%">
     <p>A paragraph</p>
</div>
</body>
</html>

ターミナルを見ると、cdk watch が変更を検知してデプロイする様子が確認できます。

Detected change to 'docker-app/index.html' (type: change). Triggering 'cdk deploy'
⚠️ The --hotswap flag deliberately introduces CloudFormation drift to speed up deployments
⚠️ It should only be used for development - never use it for your production Stacks!

この警告メッセージは、今回の変更がホットスワップでデプロイされることを意味しています。つまり、このデプロイは更新対象のリソースが提供しているサービス API を直接実行することで行われ、CloudFormation がバイパスされます。このため、CloudFormation テンプレートとデプロイされたアプリケーションコードの間にドリフトが発生します。このようなドリフト発生を回避するため、本番環境では絶対にホットスワップを使用してはいけません。ホットスワップにより高速なデプロイができますが、CloudFormation のように安全にデプロイできる機能ではないため、ホットスワップは高速なコーディング・コンパイル・テストのループを回す必要がある開発環境での使用に最適です。watch 実行中にホットスワップを無効にしたい場合は、watch 実行時に --no-hotswap フラグを指定してください。CloudFormation とアプリケーション間のドリフトを完全に取り除く必要がある場合は、cdk deploy を実行することで CloudFormation フルデプロイが行われます。cdk watch を実行せずにホットスワップデプロイを行いたい場合は、cdk deploy --hotswap を実行してください。

デプロイが完了したら、ページを更新してください。Hello World のページが以下のように更新されているはずです。

インフラストラクチャコードの変更

すべてのリソース変更がホットスワップできるわけではありません。Lambda 関数のコード変更、ECS サービスのコンテナ定義変更、Step Functions のステーマシン定義変更などがホットスワップに対応しています。他のリソースに変更が入った場合は、ホットスワップデプロイではなく CloudFormation フルデプロイを行う必要があります。この動作を確認するために、lib/cdk-watch-stack.ts のコードを以下のように変更してください。

import { 
    Stack,
    StackProps,
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_ecs_patterns as ecs_patterns,
} from 'aws-cdk-lib';

import { Construct } from 'constructs';

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

        // Fargate does not work with default VPCs
        const vpc = new ec2.Vpc(this, 'Vpc', {
            maxAzs: 2, // ALB requires 2 AZs
            natGateways: 2,    //changing this property does not trigger a hotswap, and a full deployment occurs instead
        });

        new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'EcsService', {
            vpc,
            taskImageOptions: {
                image: ecs.ContainerImage.fromAsset('docker-app'),
                containerPort: 80,
            },
        });
    }
}

ターミナルウィンドウを確認してください。アセットの公開が完了すると、次のメッセージが出力されます。

⚠️ The following non-hotswappable changes were found. To reconcile these using CloudFormation, specify --hotswap-fallback
    logicalID: VpcPrivateSubnet2DefaultRoute060D2087, type: AWS::EC2::Route, rejected changes: NatGatewayId, reason: This resource type is not supported for hotswap deployments

これは、ホットスワップデプロイができない変更が含まれていたことを意味しています。通常、アプリケーションのインフラストラクチャに対する変更はホットスワップできませんが、アプリケーションで使用されるアセットに対する変更はホットスワップ可能です。今回行った変更は vpc で使用されている natGateways の数を増やすもので、インフラストラクチャの変更に該当するためホットスワップができません。デフォルトでは、ホットスワップに対応していない変更が入った場合には、watch はデプロイを行いません。--hotswap-fallback のフラグを付けることで、ホットスワップに対応していない変更が入った場合に CloudFormation フルデプロイを実行するようフォールバックする動作となります。

ロールバックの無効化

デフォルトでは、cdk watch--no-rollback を使用しません。ロールバックを無効化する前に、cdk watch を実行しているターミナルウィンドウで ^C (Ctrl + C) を入力し、その後ターミナルで cdk deploy コマンドを実行してください。

cdk deploy

まずは CloudFormation フルデプロイを実行し、前の手順で行った変更を反映させます。これらの変更は CloudFormation によって「置き換えが必要な変更」とみなされ、--no-rollback フラグがサポートされません。なぜなら、ApplicationLoadBalancedFargateService を構成するリソースの 1 つに対する削除と作成を必要とするためです。デプロイが完了したら、次のコマンドを実行します。

cdk watch --no-rollback --hotswap-fallback

最初に cdk watch を実行したときと同じ内容が出力されるはずです。実行したら、lib/cdk-watch-stack.ts を以下のように変更してください。

import { 
    Stack,
    StackProps,
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_ecs_patterns as ecs_patterns,
} from 'aws-cdk-lib';

import { Construct } from 'constructs';

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

        // Fargate does not work with default VPCs
        const vpc = new ec2.Vpc(this, 'Vpc', {
            maxAzs: 2, // ALB requires 2 AZs
            natGateways: 2, 
        });

        new ec2.CfnVPC(this, 'mycfnvpc', { 
            cidrBlock: '10.0.0/16',       //intentionally incorrect code
        });

        new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'EcsService', {
            vpc,
            taskImageOptions: {
                image: ecs.ContainerImage.fromAsset('docker-app'),
                containerPort: 80,
            },
        });
    }
}

この変更では、無効な cidrBlock を指定していることに注意してください。今回はインフラストラクチャの変更なのでホットスワップができず、CloudFormation フルデプロイになることが予想されます。cdk watch が実行されているため、以下のようなエラーメッセージが表示されます。

Could not perform a hotswap deployment, as the stack CdkWatchStack contains non-Asset changes
Falling back to doing a full deployment
CdkWatchStack: creating CloudFormation changeset...
3:17:02 PM | CREATE_FAILED | AWS::EC2::VPC | mycfnvpc
Value (10.0.0/16) for parameter cidrBlock is invalid. This is not a valid CIDR block. (Service: AmazonEC2; Status Code: 400; Error Code: InvalidParameterValue; Request ID: 4b670ce5-32bd-46dd-88de-33765f18d479; Proxy: null)

❌ CdkWatchStack failed: Error: The stack named CdkWatchStack failed to deploy: UPDATE_FAILED (The following resource(s) failed to create: [mycfnvpc]. )
at Object.waitForStackDeploy (/usr/local/lib/node_modules/aws-cdk/lib/api/util/cloudformation.ts:309:11)
at processTicksAndRejections (internal/process/task_queues.js:95:5)
at prepareAndExecuteChangeSet (/usr/local/lib/node_modules/aws-cdk/lib/api/deploy-stack.ts:337:26)
at CdkToolkit.deploy (/usr/local/lib/node_modules/aws-cdk/lib/cdk-toolkit.ts:194:24)
at CdkToolkit.invokeDeployFromWatch (/usr/local/lib/node_modules/aws-cdk/lib/cdk-toolkit.ts:594:7)
at FSWatcher.<anonymous>(/usr/local/lib/node_modules/aws-cdk/lib/cdk-toolkit.ts:310:9)

--no-rollback を指定したことで、CloudFormation によるロールバックは行われませんでした。では以下のように変更し、cidrBlock を有効な値にしてみましょう。

import { 
    Stack,
    StackProps,
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_ecs_patterns as ecs_patterns,
} from 'aws-cdk-lib';

import { Construct } from 'constructs';

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

        // Fargate does not work with default VPCs
        const vpc = new ec2.Vpc(this, 'Vpc', {
            maxAzs: 2, // ALB requires 2 AZs
            natGateways: 2,                                                                
        });
    
        new ec2.CfnVPC(this, 'mycfnvpc', {
            cidrBlock: '10.0.0.0/16',       //corrected code                                                       
        });

        new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'EcsService', {
            vpc,
            taskImageOptions: {
                image: ecs.ContainerImage.fromAsset('docker-app'),
                containerPort: 80,
            },
        });
    }
}

cdk watch が変更を検知し、以下のようなメッセージを出力して自動的にデプロイが成功するはずです。

Could not perform a hotswap deployment, as the stack CdkWatchStack contains non-Asset changes
Falling back to doing a full deployment
CdkWatchStack: creating CloudFormation changeset...

 ✅  CdkWatchStack
 
Outputs:
CdkWatchStack.EcsServiceLoadBalancerDNS6D595ACE = CdkWa-EcsSe-T2ZOAGRO8LGP-xxxxxxxxx.us-east-2.elb.amazonaws.com
CdkWatchStack.EcsServiceServiceURLE56F060F = http://CdkWa-EcsSe-T2ZOAGRO8LGP-xxxxxxxxx.us-east-2.elb.amazonaws.com

Stack ARN:
arn:aws:cloudformation:us-east-2:xxxxxxxxxxxx:stack/CdkWatchStack/95d784f0-4d73-11ec-a8b8-xxxxxxxxxxxx

クリーンアップ

デプロイしたスタックとアプリケーションを削除するには、CDK プロジェクトのルートディレクトリで cdk destroy コマンドを実行してください。

cdk destroy

まとめ

cdk watch を使用することで、可能な場合はホットスワップにより CloudFormation がバイパスされ、より迅速にスタックの更新を行うことができます。すべてのリソースの変更がホットスワップ可能なわけではありません。ホットスワップデプロイが実行できない場合は、watch が CloudFormation フルデプロイにフォールバックするフラグ --hotswap-fallback を追加することもできます。ホットスワップによって意図的なドリフトが発生するため、本番環境では使用しないでください。必要に応じて --no-hotswap フラグを追加することで、ホットスワップを無効にすることもできます。--no-rollback フラグを追加して cdk watch を実行すると、更新失敗時のロールバックが無効になります。ただし、置換タイプの更新では --no-rollback フラグがサポートされておらず、フラグを追加した状態でデプロイしようとするとエラーになるので、ご注意ください。