LocalStackを使ってCDKをローカル環境で動作確認をしてみる

記事タイトルとURLをコピーする

CS1の石井です。

タイトルの通りLocalStackというツールを用いてCDKをローカルで動作確認を行う記事となります。

対象読者

  • CDKワークショップを終わらせている方
  • devcontainerを使える方

記事を書いた動機

私は普段仕事でCDKをよく使うのですが、コードの記載や修正をした後、動作確認をnpx cdk synth で確認しています。

これはTypescriptで記載されたCDKをCloudFormationのYamlファイルに変換する作業であり、CDKのコードに不備があれば正しく出力することはできません。

しかし、これは実際にデプロイしていないため、実際にデプロイしてみるとApiGatewayの定義でよくデプロイ失敗することもよくありました。 さらにCDKはデプロイ作業はCloudFormationが動作するため非常にデプロイまでの時間が長いです。

そのため、前々からSAMやlocalDynamoDBのようなツールを使っていたのですが、LocalStackというツールが快適だったので記事にまとめようと思いました。

LocalStackとは何か

LocalStackは、AWS(Amazon Web Services)のクラウドサービスをローカル環境で模倣するためのツールです。 開発者は自分のマシン上でAWSのような環境を作り出し、クラウドサービスと同様の機能をテストや開発に使用できます。 無料版と有料のPRO版の2種類がありますが、無料版でもLocalStackは多くのAWSサービスをサポートしており実際のAWS環境を使わずに、ローカルでの開発やテストが可能になります。

どのサービスが使えて無料かPROかの表は以下のリンクをご確認ください。

docs.localstack.cloud

LocalStackを試す

まずは環境を準備します。

私はdevcontainerが好きなので、本記事ではdevcontainerを使って作業を進めていきます。

以下のコマンドでdevcontainerのファイルを作成して起動します。

mkdir -p llstackcdk/devcontainer
touch llstackcdk/.devcontainer/devcontainer.json

.devcontainer.jsonに記載する内容は以下です。

{
    "name": "Node.js & TypeScript",
    "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
    "features": {
        "ghcr.io/devcontainers/features/aws-cli:1": {},
        "ghcr.io/devcontainers/features/docker-in-docker:2": {},
        "ghcr.io/devcontainers/features/git:1": {},
        "ghcr.io/devcontainers-contrib/features/aws-cdk:2": {},
        "ghcr.io/devcontainers-contrib/features/localstack:2": {},
        "ghcr.io/devcontainers/features/python:1": {
            "installTools": true,
            "version": "3.10"
        },
    },
    // cdklocal使った時の警告メッセージを抑制
    "containerEnv": {
        "ENV": "local",
        "JSII_SILENCE_WARNING_UNTESTED_NODE_VERSION": "true"
    },
    // Windowsの人は${localEnv:USERPROFILE}に置き換え
    "mounts": [
        "source=/${localEnv:HOME}/.aws/config,target=/home/node/.aws/config,type=bind,consistency=cached",
        "source=/${localEnv:HOME}/.aws/credentials,target=/home/node/.aws/credentials,type=bind,consistency=cached",
        "source=/${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,consistency=cached"
    ],
    // 
    "onCreateCommand": 
    "npx cdk init --language typescript; \
    npm install -g aws-cdk-local esbuild @types/aws-lambda aws-sdk; \
    pip install awscli-local"
}

最終的に以下のディレクトリ構造になっているかと思います。

なお、本記事で使用するサンプルとディレクトリ構造は最終的に以下となっております。

node ➜ /workspaces/llstackcdk (master) $ tree .devcontainer/
.devcontainer/
└── devcontainer.json
ディレクトリ/ファイル 説明
.devcontainer/ 開発環境設定用ディレクトリ
devcontainer.json 開発コンテナの設定ファイル

編集が完了したらdevcontainerで開発環境を起動し、localstack startのコマンドで起動してみます。

擬似AWS環境にbootstrapを実行

問題なく起動できたかと思います、続いて仮想AWS環境に以下のコマンドでbootstrapを行います。

cdklocal bootstrap aws://000000000000/ap-northeast-1

※なお、以降のLocalStackへ作業はawslocalとcdklocalというコマンド使用します。

devcontainerで起動した人は最初からインストールされていますが、それ以外の環境で実行している方はdevcontainerの「onCreateCommand」に記載されたコマンドと export ENV=local を実行してください。

環境の確認

bootstrapが完了したため、一旦LocalStackの擬似AWS環境を覗いてみます。

VPCのデータを引っ張ってみると、すでに初期状態でVPCやらサブネットが作成されているようです。

node ➜ /workspaces/llstackcdk (master) $ awslocal ec2 describe-subnets
{
    "Subnets": [
        {
            "AvailabilityZone": "ap-northeast-1a",
            "AvailabilityZoneId": "apne1-az4",
            "AvailableIpAddressCount": 4091,
            "CidrBlock": "172.31.0.0/20",
            "DefaultForAz": true,
            "MapPublicIpOnLaunch": true,
            "State": "available",
            "SubnetId": "subnet-d0ef4f8b",
            "VpcId": "vpc-77f91442",
            "OwnerId": "000000000000",
            "AssignIpv6AddressOnCreation": false,
            "Ipv6CidrBlockAssociationSet": [],
            "SubnetArn": "arn:aws:ec2:ap-northeast-1:000000000000:subnet/subnet-d0ef4f8b",
            "Ipv6Native": false
        },
        {
            "AvailabilityZone": "ap-northeast-1c",
            "AvailabilityZoneId": "apne1-az1",

CDKデプロイを試みる

bootstrapが完了したので早速CDKのコードをデプロイしてみます。

デプロイするサンプルはApiGatewayとLambdaの構成です。エンドポイントに対してcurlを実行すればハローワールドが帰ってきます。

以下にサンプルコードを記載します。

ファイル名:/workspaces/llstackcdk/lib/llstackcdk-stack.ts

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


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

    // Python Lambda
    const sampleLambda = new lambda.Function(this, 'SampleLambda', {
      code: lambda.Code.fromAsset('lib/getLambda'), // Pythonコードを含むディレクトリ
      handler: 'sample.lambda_handler', // ハンドラ関数
      runtime: lambda.Runtime.PYTHON_3_10, // Python 3.10
    });

    // API Gateway
    const sampleApi = new apigateway.RestApi(this, `SampleApi`);
    const lambdaIntegration = new apigateway.LambdaIntegration(sampleLambda);
    sampleApi.root.addMethod('GET', lambdaIntegration);

  }
}

Lambdaの本体コードは以下です。

ファイル名:/workspaces/llstackcdk/lib/getLambda/sample.py

import json

def lambda_handler(event, context):
    # Hello World メッセージを作成
    message = "Hello World from AWS Lambda!"

    # レスポンスの作成
    response = {
        "statusCode": 200,
        "body": json.dumps({"message": message})
    }

    return response


最終的に以下の構成になっていれば問題ありません。

node ➜ /workspaces/llstackcdk (master) $ tree lib/
lib/
├── getLambda
│   └── sample.py
└── llstackcdk-stack.ts
ディレクトリ/ファイル 説明
lib/ ルートディレクトリ
getLambda/ Lambda格納用ディレクトリ
sample.py サンプルのLambda、主にこのLambdaを改造します
llstackcdk-stack.ts LambdaやApiGatewayの構成を記載したスタックファイル

上記ファイルが編集できたら npx cdklocal deploy でデプロイを実行します。

node ➜ /workspaces/llstackcdk (master) $ npx cdklocal deploy 
Bundling asset LlstackcdkStack/SampleLambda/Code/Stage...

〜省略〜

 ✅  LlstackcdkStack

✨  Deployment time: 10.14s

Outputs:
LlstackcdkStack.SampleApiEndpoint9FDD6102 = https://lupitt6nkf.execute-api.localhost.localstack.cloud:4566/prod/
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:000000000000:stack/LlstackcdkStack/492b5d1e

✨  Total time: 15.42s


node ➜ /workspaces/llstackcdk (master) $ 

問題なくデプロイでき、ApiGatewayもローカル用のURLが生成されているようです。

とりあえずエンドポイントはコンテナ内の環境変数に保存しておきます。

API_ID=$(awslocal apigateway get-rest-apis | jq -r '.items[0].id')
STAGE_NAME=$(awslocal apigateway get-stages --rest-api-id $API_ID | jq -r '.item[0].stageName')
ENDPOINT_URL="https://$API_ID.execute-api.localhost.localstack.cloud:4566/$STAGE_NAME/"

curlでの動作確認

特に難しく考えることもなく、先ほど取得したエンドポイントに対してcurl実行してみます。

node ➜ /workspaces/llstackcdk (master) $ API_ID=$(awslocal apigateway get-rest-apis | jq -r '.items[0].id')
STAGE_NAME=$(awslocal apigateway get-stages --rest-api-id $API_ID | jq -r '.item[0].stageName')
ENDPOINT_URL="https://$API_ID.execute-api.localhost.localstack.cloud:4566/$STAGE_NAME/"
node ➜ /workspaces/llstackcdk (master) $  curl $ENDPOINT_URL
{"message": "Hello World from AWS Lambda!"}node ➜ /workspaces/llstackcdk (master) $ 
node ➜ /workspaces/llstackcdk (master) $ 

なんの変哲もないhello Worldですが、ここからLambdaを改造してみます。

まずは変更を即反映させたいため npx cdklocal watch コマンドを実行してファイル変更後すぐデプロイできるようにします。

※watchコマンドを使わない場合更新の際は都度再デプロイしてください。うまくいかない場合は一度localstack stopを実行し再度startとbootstrapを実行してdeployしなおしてください。

※VSCodeの画面分割でターミナルを分割しておくと便利です

続いてLambdaの表示メッセージを以下のように編集してみます。

import json

def lambda_handler(event, context):
    # Hello World メッセージを作成
    message = "Hogehoge"

    # レスポンスの作成
    response = {
        "statusCode": 200,
        "body": json.dumps({"message": message})
    }

    return response

ファイルを保存したとき、watchのコンソールから自動的にデプロイが実行されます。

早速curlコマンドを実行してみます。

node ➜ /workspaces/llstackcdk (master) $ curl $ENDPOINT_URL
{"message": "Hogehoge"}node ➜ /workspaces/llstackcdk (master) $ 

問題なくhogehogeで帰ってきました、watchも本家のCDKで使えますが、LocalStackの方が反映が早いです。

本物のAWS環境に繋がず高速で試行錯誤することができるため、開発スピードが上がると思います。

一般的なサーバレス構成でLocalStackを試す

LocalStackが結構面白そうなツールだとわかったところで、一般的なサーバレス構成をデプロイしてみます。

構成としてはLambda + Apigateway + DynamoDBで何かのデータを取得したりするサンプルを作成してみます。

まずはCDKのスタックの内容を以下のように書き換えます。

※watchコマンドは継続中の方は、ファイルを保存と同時にデプロイが始まります。 通常のAWS環境であれば1分ぐらいデプロイに時間がかかりそうなリソースですが、LocalStackだと15秒でデプロイが完了してくれました。非常にgoodです。

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


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

    // DynamoDB Table
    const table = new dynamodb.Table(this, 'SampleTable', {
      partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
      tableName: 'SampleTable',
    });

    // 環境変数 ENV を読み込む
    const envValue = process.env.ENV || 'prod'; // ENVが未定義の場合、デフォルト値を使用

    // Python Lambda
    const sampleLambda = new lambda.Function(this, 'SampleLambda', {
      code: lambda.Code.fromAsset('lib/getLambda'), // Pythonコードを含むディレクトリ
      handler: 'sample.lambda_handler', // ハンドラ関数
      runtime: lambda.Runtime.PYTHON_3_10, // Python 3.10
      environment: {
        TABLE_NAME: table.tableName,
        ENV: envValue // ENV 環境変数の値を使用
      },
    });

    // DynamoDBへの読み取り権限をLambda実行ロールに付与
    table.grantReadData(sampleLambda);

    // API Gateway
    const sampleApi = new apigateway.RestApi(this, 'SampleApi');
    const lambdaIntegration = new apigateway.LambdaIntegration(sampleLambda);

    //  リソースの作成とGETメソッドの追加
    const getResource = sampleApi.root.addResource('get');
    getResource.addMethod('GET', lambdaIntegration, {
      requestParameters: {
        'method.request.querystring.id': true,
      },
    });

  }
}

次にLambdaのPythonファイルです。

ファイル名:/workspaces/llstackcdk/lib/getLambda/sample.py

import json
import os
import boto3
import decimal
from botocore.exceptions import ClientError


# 環境変数からENVを取得
env = os.getenv('ENV')
if env == 'local':
    session = boto3.Session(
        aws_access_key_id='dummy',
        aws_secret_access_key='dummy',
        region_name='ap-northeast-1'
    )
    endpoint=f"http://{os.environ['LOCALSTACK_HOSTNAME']}:4566"
    dynamodb = session.resource(
        service_name='dynamodb', 
        endpoint_url=endpoint
    )
else:
    # 本番環境用の初期化
    dynamodb = boto3.resource('dynamodb')


# Decimal型をfloatに変換するヘルパー関数
def decimal_to_float(obj):
    if isinstance(obj, decimal.Decimal):
        return float(obj)
    raise TypeError

def lambda_handler(event, context):

    table_name = os.environ['TABLE_NAME']
    table = dynamodb.Table(table_name)


    item_id = event['queryStringParameters']['id']
    response = table.get_item(Key={'id': item_id})

    item = response.get('Item', {})
    # Decimal型をfloatに変換してJSONにシリアライズ
    return {
        'statusCode': 200,
        'body': json.dumps(item, default=decimal_to_float)
    }

どちらもデプロイが完了したら下記のawslocalコマンドでdynamoDBにアイテムを格納します

awslocal dynamodb put-item   --table-name SampleTable   --item '{
    "id": {"S": "hoge"},
    "other_attribute1": {"S": "value1"},
    "other_attribute2": {"N": "123"}
  }'

アイテムの格納が完了したため、早速テストしてみます。

node ➜ /workspaces/llstackcdk (master) $ curl -X GET "${ENDPOINT_URL}get?id=hoge"
{"other_attribute1": "value1", "id": "hoge", "other_attribute2": 123.0}node ➜ /workspaces/llstackcdk (master) $ 

問題なく取れました。おめでとうございます。

ここまでは簡単な話でサンプルを参考にした内容でしたが、次の項目から書きたかった事をつらつらと書きます。

無料版で生きていくには

冒頭にも記載しましたが、LocalStackというツールは無料版と有料版で分かれています。

端的に言えば有料版はWAF、VPC-Endpoint、CloudFrontといった要素に対応しており、Lambdaのリモートデバッグも実行可能なようです。

docs.localstack.cloud

ただ、有料版を使わずとも無料版でもやりたいことは大体やれる印象を持っています。

以降は無料版でやりくりする手法を書いていこうと思います。

Lambdaのデバッグをやりたい

無料版ではステップ実行しながらデバッグというやり方ができないので、大人しくロググループで実践します。

LocalStackでもCloudWatch Logsにロググループを出力することが可能なのでそれを確認してみます。 なお、Lambdaの実行ログは先ほどのコードで作成されているので先ほどのcurlのコマンドのLambdaの動作ログを確認してみます。

node ➜ /workspaces/llstackcdk (master) $ curl -X GET "${ENDPOINT_URL}get?id=hoge"
{"other_attribute1": "value1", "id": "hoge", "other_attribute2": 123.0}node ➜ /workspaces/llstackcdk (master) $ 
node ➜ /workspaces/llstackcdk (master) $ awslocal logs describe-log-groups
{
    "logGroups": [
        {
            "logGroupName": "/aws/lambda/LlstackcdkStack-SampleLambdaB2FF4FA1-eec34fa4",
            "creationTime": 1705802802724,
            "metricFilterCount": 0,
            "arn": "arn:aws:logs:ap-northeast-1:000000000000:log-group:/aws/lambda/LlstackcdkStack-SampleLambdaB2FF4FA1-eec34fa4:*",
            "storedBytes": 262
        }
    ]
}
node ➜ /workspaces/llstackcdk (master) $ 
node ➜ /workspaces/llstackcdk (master) $ awslocal logs describe-log-streams --log-group-name '/aws/lambda/LlstackcdkStack-SampleLambdaB2FF4FA1-eec34fa4'
{
    "logStreams": [
        {
            "logStreamName": "2024/01/21/[$LATEST]28143fd5f769b1035d94c0c17ea2acb1",
            "creationTime": 1705802802731,
            "firstEventTimestamp": 1705802802708,
            "lastEventTimestamp": 1705802802720,
            "lastIngestionTime": 1705802802740,
            "uploadSequenceToken": "1",
            "arn": "arn:aws:logs:ap-northeast-1:000000000000:log-group:/aws/lambda/LlstackcdkStack-SampleLambdaB2FF4FA1-eec34fa4:log-stream:2024/01/21/[$LATEST]28143fd5f769b1035d94c0c17ea2acb1",
            "storedBytes": 262
        }
    ]
}
node ➜ /workspaces/llstackcdk (master) $ awslocal logs get-log-events --log-group-name '/aws/lambda/LlstackcdkStack-SampleLambdaB2FF4FA1-eec34fa4' --log-stream-name '2024/01/21/[$LATEST]28143fd5f769b1035d94c0c17ea2acb1'
{
    "events": [
        {
            "timestamp": 1705802802708,
            "message": "START RequestId: d3135d30-5285-4ec3-80ba-a8f020dd8e3f Version: $LATEST",
            "ingestionTime": 1705802802740
        },
        {
            "timestamp": 1705802802714,
            "message": "END RequestId: d3135d30-5285-4ec3-80ba-a8f020dd8e3f",
            "ingestionTime": 1705802802740
        },
        {
            "timestamp": 1705802802720,
            "message": "REPORT RequestId: d3135d30-5285-4ec3-80ba-a8f020dd8e3f\tDuration: 22.48 ms\tBilled Duration: 23 ms\tMemory Size: 128 MB\tMax Memory Used: 128 MB\t",
            "ingestionTime": 1705802802740
        }
    ],
    "nextForwardToken": "f/00000000000000000000000000000000000000000000000000000002",
    "nextBackwardToken": "b/00000000000000000000000000000000000000000000000000000000"
}
node ➜ /workspaces/llstackcdk (master) $ 

通常のAWS環境と同じくロググループが作成されており"logGroupName": "/aws/lambda/LlstackcdkStack-SampleLambdaB2FF4FA1-eec34fa4", という名前で保存されているのがわかります。

LocalStackでも通常のAWS環境と同じようにロググループが作成されていくのですが、マネージドコンソールなしでロググループをCLI操作で探すのはかなりきついです。 個人的にLocalStackでLambda動作のデバッグまでやるならSAMと併用した方がいい気がしました。

本番とローカルで使い分けをしたい

LocalStackで検証してから本番環境やステージング環境へデプロイしたいのですが、コードは同一が望ましいと思います。

環境分離の手法としては環境変数を用いて実装するのが一般的です。今回使ったサンプルコードもENVという環境変数をセットしてlocalとそれ以外の環境で使えるようにしています。

# 環境変数からENVを取得
env = os.getenv('ENV')
if env == 'local':
    session = boto3.Session(
        aws_access_key_id='dummy',
        aws_secret_access_key='dummy',
        region_name='ap-northeast-1'
    )
    endpoint=f"http://{os.environ['LOCALSTACK_HOSTNAME']}:4566"
    dynamodb = session.resource(
        service_name='dynamodb', 
        endpoint_url=endpoint
    )
else:
    # 本番環境用の初期化
    dynamodb = boto3.resource('dynamodb')

注意点としてはdynamoDBのセッションを作成するときに使用している「LOCALSTACK_HOSTNAME」という変数です。

これはLocalStackが用意している環境変数で自身のLocalStackが生成しているエンドポイントを出力するようです。

なお、他にも使える環境変数は多数あります、必要に応じて公式の一覧をご確認ください。

docs.localstack.cloud

なお、Lambdaの環境変数自体はCDK実行時に確定する要素であり、CDKのコードにも環境変数を取得する処理が含まれています。

    // 環境変数 ENV を読み込む
    const envValue = process.env.ENV || 'prod'; // ENVが未定義の場合、デフォルト値を使用

    // Python Lambda
    const sampleLambda = new lambda.Function(this, 'SampleLambda', {
      code: lambda.Code.fromAsset('lib/getLambda'), // Pythonコードを含むディレクトリ
      handler: 'sample.lambda_handler', // ハンドラ関数
      runtime: lambda.Runtime.PYTHON_3_10, // Python 3.10
      environment: {
        TABLE_NAME: table.tableName,
        ENV: envValue // ENV 環境変数の値を使用
      },
    });

実際に本番環境にデプロイする場合は export ENV=prod でENVを書き換えて npx cdk deploy を実行してください

無料だとできないWAFやCloudFrontはどうするか

インターネットに公開しているサイトにWAFなしだとちょっと心許ないです。

AWS WAFのcommon ruleぐらいは付けておいてもいいかなと思っていますが、WAFはLocalStackの有料版しか対応しておりません。

そのため、WAFの動作確認は実際のstgやdev環境で動作確認を行い、LocalStackではWAFの動作確認をあきらめます。

ただコード自体は同一にしたいため、こちらも環境変数の使い方で乗り切りたいと思います。

まずはWAFを作成するCDKを記載します。ちょっと長いのですがWAFがL2に対応していないためCloudFormationっぽい書き方しています。

ファイル名:/workspaces/llstackcdk/lib/waf-construct.ts

import * as wafv2 from 'aws-cdk-lib/aws-wafv2'
import { Construct } from 'constructs'
import { StackProps } from 'aws-cdk-lib'

interface WafStackProps extends StackProps {
  wafScope: 'CLOUDFRONT' | 'REGIONAL'
}

export class BaseWafConstruct extends Construct {
  public readonly wafconstruct: wafv2.CfnWebACL
  constructor(
    scope: Construct,
    id: string,
    props: WafStackProps,
    extraRules: wafv2.CfnWebACL.RuleProperty[]
  ) {
    super(scope, id)


    this.wafconstruct = new wafv2.CfnWebACL(
      this,
      `WebACLID`,
      {
        name: `HogeACL`,
        description: `HogeACL`,
        defaultAction: {
          allow: {}
        },
        scope: props.wafScope,
        visibilityConfig: {
          sampledRequestsEnabled: true,
          cloudWatchMetricsEnabled: true,
          metricName: `HogeACL`
        },

        rules: [
          {
            name: 'AWS-AWSManagedRulesKnownBadInputsRuleSet',
            priority: 0,
            overrideAction: {
              none: {}
            },
            statement: {
              managedRuleGroupStatement: {
                vendorName: 'AWS',
                name: 'AWSManagedRulesKnownBadInputsRuleSet'
              }
            },
            visibilityConfig: {
              sampledRequestsEnabled: true,
              cloudWatchMetricsEnabled: true,
              metricName: 'AWS-AWSManagedRulesKnownBadInputsRuleSet'
            }
          },
          {
            name: 'AWS-AWSManagedRulesAmazonIpReputationList',
            priority: 1,
            overrideAction: {
              none: {}
            },
            statement: {
              managedRuleGroupStatement: {
                vendorName: 'AWS',
                name: 'AWSManagedRulesAmazonIpReputationList'
              }
            },
            visibilityConfig: {
              sampledRequestsEnabled: true,
              cloudWatchMetricsEnabled: true,
              metricName: 'AWS-AWSManagedRulesAmazonIpReputationList'
            }
          },
          {
            name: 'AWS-AWSManagedRulesCommonRuleSet',
            priority: 2,
            overrideAction: {
              none: {}
            },
            statement: {
              managedRuleGroupStatement: {
                vendorName: 'AWS',
                name: 'AWSManagedRulesCommonRuleSet'
              }
            },
            visibilityConfig: {
              sampledRequestsEnabled: true,
              cloudWatchMetricsEnabled: true,
              metricName: 'AWS-AWSManagedRulesCommonRuleSet'
            }
          }
        ]
      }
    )
  }
}


export class WafConstruct extends BaseWafConstruct {
  constructor(scope: Construct, id: string, props: WafStackProps) {
    const extraRules: wafv2.CfnWebACL.RuleProperty[] = [
      {
        name: 'AWS-AWSManagedRulesLinuxRuleSet',
        priority: 3,
        overrideAction: {
          none: {}
        },
        statement: {
          managedRuleGroupStatement: {
            vendorName: 'AWS',
            name: 'AWSManagedRulesLinuxRuleSet'
          }
        },
        visibilityConfig: {
          sampledRequestsEnabled: true,
          cloudWatchMetricsEnabled: true,
          metricName: 'AWS-ManagedRulesLinuxRuleSet'
        }
      }
    ]

    super(scope, id, props, extraRules)
  }
}

次に元のスタックファイルを以下のコードを追記します

ファイル名:/workspaces/llstackcdk/lib/llstackcdk-stack.ts

    //  リソースの作成とGETメソッドの追加
    const getResource = sampleApi.root.addResource('get');
    getResource.addMethod('GET', lambdaIntegration, {
      requestParameters: {
        'method.request.querystring.id': true,
      },
    });


// 〜ここから追記〜
    // LocalStackではWAF作れないのでlocalは除外
    if (process.env.ENV != 'local') {

      const sampleApiStage = new apigateway.Stage(this, "ApiGwStage", {
        deployment: new apigateway.Deployment(this, "MyDeployment", {
          api: sampleApi,
        }),
        stageName: envValue,
      });

      const apigwwaf = new WafConstruct(this, 'WAFID',
        {
          wafScope: "REGIONAL",
        }
      )

      // APIGWとWebACLを紐付ける
      const webAclAssociation = new cdk.aws_wafv2.CfnWebACLAssociation(
        this,
        "webAclAssociation",
        {
          resourceArn: `arn:aws:apigateway:${cdk.Stack.of(this).region}::/restapis/${sampleApi.restApiId
            }/stages/${envValue}`,
          webAclArn: apigwwaf.wafconstruct.attrArn,
        }
      );
      // API Gatewayのステージが作成された後にWAF WebACLの関連付けを行う
      webAclAssociation.node.addDependency(sampleApiStage);
    }
〜省略〜

この状態でLocalStackには何も変化はありませんが、ENVをlocal以外のstgなどに変更して実際のAWS環境にデプロイしてみます。

正常にデプロイできているようです。念の為LocalStackを確認してみます。

node ➜ /workspaces/llstackcdk (master) $ aws wafv2 list-web-acls --scope REGIONAL --query 'WebACLs[*].{Name:Name, ARN:ARN}' --output text
arn:aws:wafv2:ap-northeast-1:XXXXXXXXXXXX:regional/webacl/HogeACL/XXXXXXXX-5baf-428f-a073-ca45e52b7811  HogeACL
↑本物のAWS環境に対してawsコマンドを実施

↓LocalStackに対してawslocalのコマンドを実施
node ➜ /workspaces/llstackcdk (master) $ awslocal wafv2 list-web-acls --scope REGIONAL --query 'WebACLs[*].{Name:Name, ARN:ARN}
' --output text

An error occurred (InternalFailure) when calling the ListWebACLs operation: API for service 'wafv2' not yet implemented or pro feature - please check https://docs.localstack.cloud/references/coverage/ for further information
node ➜ /workspaces/llstackcdk (master) $ 

想定通りWAFは作成されず、本番だけデプロイできました、CDKの中にifが多くなってしまうのが玉に瑕です。

WAFを要求するインターフェースがあったらどうするか?

テーマがちょっとわかりにくいのですが、以下のようなシチュエーションです。

  1. us-east-1でWAFやLambda@Edgeを作成するスタックを定義
  2. ap-northeast-1でus-east-1で作成したリソースを関連づけるCloudFrontを作成するスタックを定義

ap-northeast-1のスタックはus-east-1で作成したスタックのリソースを関連づける必要があり、ap-northeast-1のスタックを呼び出す時、us-east-1のスタックを要求するインターフェースを実装した場合です。

例えば以下のようなコードです。

〜省略〜
    // usStackをus-east-1で作成
    const usStack = new UsStack(this, `UsStack`, {
      env: { region: "us-east-1" },
      wafScope: "CLOUDFRONT",
    });

    // ap-northeast-1に流すスタックの内容にusStackのデータをスタック間共有で流すためのインターフェースを設定している場合
    const jpStack = new JpStack(this, `JpStack`, {
      env: { region: "ap-northeast-1" },
      UsStackData: usStack,  // ← WAFやLambda@EdgeはLocalStackの無料版で作れないが、スタック作成時に要求される
      crossRegionReferences: true, // usリージョンで作成したwafを参照するためtrueに設定する
    });
〜省略〜

前に作ったコードにこのようなコードがあったため、ちょっと考えました。

インターフェースを変えてWAFやLambda@EdgeをSSMパラメータを活用して値参照するスタックの作りにした方が早いと思いましたが、WAFスタックを模倣するmockを作りやってみることにしました。

〜省略〜

interface WafStackProps extends StackProps {
  wafScope: 'CLOUDFRONT' | 'REGIONAL'
}

// BaseWafConstructのモック
class MockBaseWafConstruct extends Construct {
  public readonly wafArn: string;

  constructor(scope: Construct, id: string, props: WafStackProps) {
    super(scope, id);

    // 実際のAWSリソースを作成せず、ダミーのARNを提供
    this.wafArn = "arn:aws:wafv2:::webacl/mock";
  }
}

// WafStackのモック
class MockWafStack extends Stack {
  constructor(scope: Construct, id: string, props: WafStackProps) {
    super(scope, id, props);

    new MockBaseWafConstruct(this, "MockWAF", props),

  }
}


export class LocalStage extends Stage {
  constructor(scope: Construct, id: string, props: ExtendStageProps) {
    super(scope, id, props);

    // WafStackはUSリージョンで作る時に使ってください
    const mockWafStack = new MockWafStack(this, "MockWafStack", {
      env: { region: "us-east-1" },
      wafScope: "CLOUDFRONT",
    });


    const jpStack = new JpStack(this, `JpStack`, {
      env: { region: "ap-northeast-1" },
      UsStackData: mockWafStack,  // ← 内部ではwafは実際に作られないので適当な値で作ったwafのmockで問題なし
      crossRegionReferences: true, 
    });

〜省略〜

上記のような実装で一応動きますが、ifで環境を条件にインスタンス化する、しないの処理を含めてしまいました。

このようなmockやら分岐のコードを使えば動きますが、複雑になってしまうので、素直にSSMのパラメータストアを使って値参照をするか、有料版のPROを使った方がいいと思いました。

まとめ

LocalStackを気に入っているところはやはり速さです。

CloudFormationのデプロイ速度はかなりのんびりしているので、LocalStackで一瞬にしてデプロイするときは感激しました。

LocalStackで確認すべき観点

CDKが正しく記載されているか?という観点でCDKを見た時の正解は「デプロイが正常終了すること」だと思っています。 そのため、実際に素早くデプロイできるLocalStackはCDKの記載の品質担保という意味では大きいと思います。

「デプロイが正常終了すること」が担保されていれば、CloudFormationが止まったり、パイプラインが止まる、という心配はなくなり、featureブランチをdevelopブランチにマージした時、CDKの不備で後続の人がデプロイできずに困るというシチュエーションは回避できます。

実際のAWS環境を使った方が良い点

先ほども書きましたが、プログラミングレベルのデバッグなどは、実際のAWS環境にLambdaやDynamoDBをデプロイして行った方が良いと思います。

理由はデバッグのしやすさ、ログの確認のしやすさです。特にマネージドコンソールを使えればすぐ確認できるような要素であれば、実際のAWS環境の使用をお勧めします。

以上です。