☁️

AWS CDKをlocalstackで練習する

2022/01/16に公開

極力お金をかけないようにAWSのサービスを練習していきたいという思いから、何番煎じかわからないけどAWS CDKをlocalstackで練習してみます。

AWS CDK v2が出ているので、localstackでも動作するかなぁと思ってやってみたらできたので備忘録として残すことにしました。

環境としてはWindows10のWSL2上で試しているので、macでもlinuxでも多分動くでしょう。

API Gateway + Lambda + S3をAWS CDKで定義してlocalstackで動くことを確認していきます。

AWS CDKとlocalstack

AWS CDKとは

AWSを使ったアプリケーションは、多数のサービスを互いに連携することが多く、そのようなサービスのセットアップだったりクラウドリソースの管理だったりが面倒になってきます。

そこでAWSにはAWS CloudFormationというサービスがあり、これを使えば各リソースをテンプレートとして記述でき、このテンプレートからAWSのサービスをプロビジョニングしてくれます。

しかし、AWS CloudFormationのテンプレートを使って複雑なアプリケーションを記述する際に、サービスごとに必要なパラメータや固有の組み込み関数を覚える必要があったりするので、巨大なテンプレートほど管理が難しくなってきます。

そこで、登場するのがAWS CDKで、
AWS CDKを使うことでコードからCloudFormationのテンプレートをビルドすることができます。CloudFormationで覚えるべきものは特に必要なく、プログラミング言語の知識とプロビジョニングしたいリソースの設定が明確になっていれば、CloudFormation知らなくてもIaCできちゃいます。

下記のサポートされている言語を使ってAWSのリソースを定義していくことができるようです。(2022年1月現在) (参考)

  • JavaScript
  • TypeScript
  • Python
  • Java
  • C#
  • Go (in Developer Preview)

リソースのプロビジョニングは基本静的に行われると思うので、静的型付き言語でCDKを記述したほうが補完が効いて便利だと思います。

localstackとは

localstackとは簡単に言えば、AWSサービスのmockフレームワークです。

AWSの各サービスをローカル環境でmockすることで、AWSに課金せずにAWSのサービスを触れます。

用途としては、ローカル環境でAWSのサービスを試してみたり、AWSのサービスにデプロイする前のtestとしてCIに導入したりできます。

Free版とPro版があり、使用できるサービスに違いがでてきます(参考)。

AppSyncとかちょっとリッチなやつを使うならProなんですが、練習なんでFreeで。

必要なもの

  • aws cli
    • aws cli の docker を利用
    • aws-cli/2.4.11
  • aws-cdk
    • v2.3.5
  • docker
    • Docker version 20.10.12, build e91ed57
  • docker-compose
    • docker-compose version 1.29.2, build 5becea4c
  • localstack
    • tag 0.12.19のdocker image

今回はdockerを使ってlocalstackを操作していきます。

https://hub.docker.com/r/localstack/localstack

下準備

aws cliを楽して使えるようにエイリアスの設定しておきましょう。

alias awsd='docker run --rm -ti -v ~/.aws:/root/.aws -v $(pwd):/aws amazon/aws-cli --profile=localstack'

docker aws cliでaws cliの設定します。

awsd configure

$ awsd configure 
AWS Access Key ID [None]: dummy
AWS Secret Access Key [None]: dummy
Default region name [None]: ap-northeast-1
Default output format [None]: json

ip aでdockerのipアドレスを確認しておきます(後で使う)。

$ ip a | grep docker
7: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    inet 172.18.0.1/16 brd 172.18.255.255 scope global docker0

面倒なので環境変数として登録しておきましょう。
export LOCALSTACK_IP=172.18.0.1

localstackを試す

まずはdocker単体でlocalstackを試してみます。S3だったりlambdaがうまく機能するか確認しましょう。

S3のサービスを試してみる

dockerでlocalstackを起動して、s3 bucketを作成して、s3にfileを適当にuploadできることを確認してみます。
別のウィンドウかなんかでdocker runしておきます。

$ docker run --rm -it -p 4566:4566 -p 4571:4571 localstack/localstack:0.12.19
$ awsd s3 mb s3://test-bucket --endpoint-url=http://$LOCALSTACK_IP:4566
make_bucket: test-bucket

$ awsd s3 ls --endpoint-url=http://$LOCALSTACK_IP:4566
2022-01-16 10:22:17 test-bucket

$ echo "hello" > test.txt

$ awsd s3 --endpoint-url=http://$LOCALSTACK_IP:4566 cp test.txt s3://test-bucket
Completed 6 Bytes/6 Bytes (219 Bytesupload: ./test.txt to s3://test-bucket/test.txt

$ awsd s3 ls --endpoint-url=http://$LOCALSTACK_IP:4566 s3://test-bucket
2022-01-16 10:22:45          6 test.txt

Lambdaのサービスを試してみる

同様にして次はlambdaの動作を確認してみます。

まずはlambdaで動かす適当な関数を作成します。

今回はお手軽にpythonで、依存関係のあるデプロイメントパッケージについては別で考えることにして、簡単にs3のtest.txtを読み込むためのスクリプトを書いてみます。

lambda_function.py

import boto3
import os

LOCALSTACK_IP = os.environ['LOCALSTACK_IP']

# localstackのendpointを指定

s3 = boto3.resource(
    aws_access_key_id='dummy',
    aws_secret_access_key='dummy',
    region_name='ap-northeast-1',
    service_name='s3',
    endpoint_url = f'http://{LOCALSTACK_IP}:4566'
)

def lambda_handler(event, context):
    bucket = 'test-bucket' 
    key = 'test.txt'

    res = s3.Bucket(bucket).Object(key).get()
    body = res['Body'].read()
    return {
        "statusCode": 200,
        "body": body,
    }

この情報を参考にzip化します。

$ zip lambda.zip lambda_function.py 
  adding: lambda_function.py (deflated 35%)

zip化したlambdaをlocalstackにデプロイしてみましょう。

$ awsd lambda create-function --function-name="test-function" --runtime=python3.7 --role="arn:aws:iam::123456789012:role/service-role/lambda-test-role" --handler=lambda_function.lambda_handler --zip-file fileb://lambda.zip --endpoint-url=http://$LOCALSTACK_IP:4566 --environment "Variables={LOCALSTACK_IP=$LOCALSTACK_IP}"
{
    "FunctionName": "test-function",
    "FunctionArn": "arn:aws:lambda:ap-northeast-1:000000000000:function:test-function",
    "Runtime": "python3.7",
    "Role": "arn:aws:iam::123456789012:role/service-role/lambda-test-role",
    "Handler": "lambda_function.lambda_handler",
    "CodeSize": 552,
    "Description": "",
    "Timeout": 3,
    "LastModified": "2022-01-16T10:24:36.017+0000",
    "CodeSha256": "o25yVYgkWJA8JsW+cSYQu6o6psRwlg01DVsW/Rr2InU=",
    "Version": "$LATEST",
    "VpcConfig": {},
    "Environment": {
        "Variables": {
            "LOCALSTACK_IP": "172.18.0.1"
        }
    },
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "RevisionId": "569fecc6-c8b4-4851-86de-cab341f5b5c2",
    "State": "Active",
    "LastUpdateStatus": "Successful",
    "PackageType": "Zip"
}

間違って上げてしまったらdelete-functionして作り直すかupdate-function-codeします。

awsd lambda delete-function --function-name="test-function" --endpoint-url=http://${LOCALSTACK_IP}:4566

いい感じにデプロイできたら、invokeしてみましょう。

$ awsd lambda invoke --function-name test-function out.json --endpoint-url=http://$LOCALSTACK_IP:4566
{
    "StatusCode": 200,
    "LogResult": "",
    "ExecutedVersion": "$LATEST"
}

lambdaはS3にファイルの内容を取得するやつなので、lambdaの実行結果(out.json)でファイル内容が取得できているか確認してみる。

$ cat out.json 
{"body":"hello\n","statusCode":200}

簡単にですが、localstackを試すことができたので次はcdkも使って、リソースを定義してIaCしていきましょう。

ここまでのディレクトリ構成はこんな感じ。

.
├── lambda.zip
├── lambda_function.py
├── out.json
└── test.txt

cdkを使ってlocalstackにリソースをdeployする

cdklocalを使ってcdkプロジェクトを作成する

普通にnpm install aws-cdk-local aws-cdkしてcdklocal initします。

ただし、cdklocal init は空のディレクトリで行わないといけないので、mkdir infra; cd infraしてそこでinitしましょう(なんとなくtypescriptを選びました)。今回はひとつ上のディレクトリでcdklocalをinstallしているので、../node_modules/.bin/cdklocalとしてcdklocalのコマンドを叩いていきます。

とりあえずsample-appを作ってみます。sample-appではリソースとしてSQSとSNSが定義されています。

../node_modules/.bin/cdklocal init sample-app --language=typescript

ついでにわかりやすいようにlambda_functionはlambdaディレクトリとか作ってまとめておきます。

init後のディレクトリ構成。

.
├── infra
│   ├── README.md
│   ├── bin
│   ├── cdk.json
│   ├── jest.config.js
│   ├── lib
│   ├── node_modules
│   ├── package-lock.json
│   ├── package.json
│   ├── test
│   └── tsconfig.json
├── lambda
│   ├── lambda.zip
│   ├── lambda_function.py
│   ├── out.json
│   └── test.txt
├── node_modules
│   ├── aws-cdk
│   ├── aws-cdk-local
│   └── diff
├── package-lock.json
└── package.json

なんとなく、これも練習ということで、docker-composeでlocalstackを扱えるようにyamlを書いてみました。

version: "3.8"

services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}"
    image: localstack/localstack:0.12.19
    network_mode: bridge
    ports:
      - "4566:4566"
      - "4571:4571"
    environment:
      - SERVICES=${SERVICES-}
      - DEBUG=${DEBUG-}
      - DATA_DIR=${DATA_DIR-}
      - PORT_WEB_UI=${PORT_WEB_UI- }
      - LAMBDA_EXECUTOR=${LAMBDA_EXECUTOR-}
      - KINESIS_ERROR_PROBABILITY=${KINESIS_ERROR_PROBABILITY- }
      - HOST_TMP_FOLDER=${TMPDIR:-/tmp/}localstack
      - DOCKER_HOST=unix:///var/run/docker.sock
      - LOCALSTACK_HOSTNAME=localhost
    volumes:
      - "${TMPDIR:-/tmp}/localstack:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

docker-compose up -dでlocalstackを起動させておきます。

続いてdeployのためにまずはbootstrapします。

../node_modules/.bin/cdklocal bootstrap --profile=localstackを実行して、

$ ../node_modules/.bin/cdklocal bootstrap --profile=localstack
 ⏳  Bootstrapping environment aws://000000000000/ap-northeast-1...
CDKToolkit: creating CloudFormation changeset...
 ✅  Environment aws://000000000000/ap-northeast-1 bootstrapped.

上記の出力が表示されればbootstrapが完了ですので、早速サンプルをdeployしてみましょう。

$ ../node_modules/.bin/cdklocal deploy --profile=localstack

✨  Synthesis time: 7.71s

This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬──────────────────┬────────┬──────────────────┬──────────────────┬───────────────────┐
│   │ Resource         │ Effect │ Action           │ Principal        │ Condition         │
├───┼──────────────────┼────────┼──────────────────┼──────────────────┼───────────────────┤
│ + │ ${InfraQueue.Arn │ Allow  │ sqs:SendMessage  │ Service:sns.amaz │ "ArnEquals": {    │
│   │ }                │        │                  │ onaws.com        │   "aws:SourceArn" │
│   │                  │        │                  │                  │ : "${InfraTopic}" │
│   │                  │        │                  │                  │ }                 │
└───┴──────────────────┴────────┴──────────────────┴──────────────────┴───────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
InfraStack: deploying...
[0%] start: Publishing 8d676cc0cf59dc2cf4a373ed0381e270c534d9be9521322b3d416393af5f8070:current_account-current_region
[100%] success: Published 8d676cc0cf59dc2cf4a373ed0381e270c534d9be9521322b3d416393af5f8070:current_account-current_region
InfraStack: creating CloudFormation changeset...






 ✅  InfraStack

✨  Deployment time: 5.84s

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:000000000000:stack/InfraStack/df24d1bb

✨  Total time: 13.55s

ちゃんとdeploy出てきているか確認します。

cdk initのsample-appで作成されるリソースはsns topicがあるので、このリソースがlocalstack上で作成されているか確認していきます。

$ awsd sns list-topics --endpoint-url=http://$LOCALSTACK_IP:4566
{
    "Topics": [
        {
            "TopicArn": "arn:aws:sns:ap-northeast-1:000000000000:InfraStack-InfraTopic44770A4E-b1ee5f5a"
        }
    ]
}

これでlocalstackを使ったcdklocalの動作確認としては完了!

自前のlambdaとかS3とかAPI Gatewayをdeploy

SQSやSNSだけでなく、lambdaとS3もdeployしましょう。
また、実際のサービスで使用できるようにAPI Gatewayも定義して、endpointを叩けばlambdaが実行されて、S3にファイルがputされるようにCDKで作っていきます。

まずはS3 Bucket名をCDKから取得できるように、lambdaを少しだけ書き換えます。deployするためにちゃんとzip化するのを忘れないこと!

import json
import boto3
import os
from datetime import datetime

import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

LOCALSTACK_IP = os.environ['LOCALSTACK_IP']
BUCKET_NAME = os.environ['BUCKET_NAME']
REGION = os.environ['AWS_REGION']

# localstackのendpointを指定
s3 = boto3.resource(
    service_name='s3',
    endpoint_url = f'http://{LOCALSTACK_IP}:4566',
    region_name=REGION
)

def lambda_handler(event, context):
    datestr = datetime.now().strftime('%Y%m%d-%H%M%S')
    key = f'test_{datestr}.txt'
    file_contents = 'Hello! Lambda File!!'

    s3.Bucket(BUCKET_NAME).put_object(
        Key=key,
        Body=file_contents
    )

    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": 'File created'
        }),
    }

S3とLambda, API Gatewayをcdkで追加定義する。

import { Duration, Stack, StackProps } from "aws-cdk-lib";
import * as sns from "aws-cdk-lib/aws-sns";
import * as subs from "aws-cdk-lib/aws-sns-subscriptions";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as cdk from "aws-cdk-lib";
import * as apigw from "aws-cdk-lib/aws-apigateway";

import { Construct } from "constructs";
export class InfraStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const queue = new sqs.Queue(this, "InfraQueue", {
      visibilityTimeout: Duration.seconds(300),
    });

    const topic = new sns.Topic(this, "InfraTopic");

    topic.addSubscription(new subs.SqsSubscription(queue));

    // Define S3 bucket resource
    const testBucket = new s3.Bucket(this, "TestBucket");

    const testLambdaRole = new iam.Role(this, "TestLambdaRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
    });
    testLambdaRole.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaBasicExecutionRole")
    );

    // Define Lambda resource
    const testLambda = new lambda.Function(this, "TestLambda", {
      runtime: lambda.Runtime.PYTHON_3_7,
      handler: "lambda_function.lambda_handler",
      code: lambda.Code.fromAsset("../lambda/lambda.zip"),
      environment: {
        LOCALSTACK_IP: `${process.env.LOCALSTACK_IP}`,
        BUCKET_NAME: `${testBucket.bucketName}`,
      },
      role: testLambdaRole,
    });

    testBucket.grantPut(testLambda);

    // Define API Gateway
    // 本当ならLambdaRestApiで簡単にしたいけど、localstack上でendpointを作るので、、
    const testApiGW = new apigw.LambdaRestApi(this, "TestApiGW", {
      handler: testLambda,
    });

    new cdk.CfnOutput(this, "TestLambdaName", {
      value: testLambda.functionName,
    });
    new cdk.CfnOutput(this, "TestS3BucketName", {
      value: testBucket.bucketName,
    });
    new cdk.CfnOutput(this, "TestApiGwEndPoint", {
      value: `http://localhost:4566/restapis/${testApiGW.restApiId}/prod/_user_request_${testApiGW.root.path}`,
    });
  }
}

これをdeployして、Outputを確認する。

 ✅  InfraStack

✨  Deployment time: 31.57s

Outputs:
InfraStack.TestApiGWEndpointF4D06F73 = https://vvv23joyjg.execute-api.ap-northeast-1.localhost/prod/
InfraStack.TestApiGwEndPoint = http://localhost:4566/restapis/vvv23joyjg/prod/_user_request_/
InfraStack.TestLambdaName = InfraStack-TestLambda2F70C45E-4c84ea0f
InfraStack.TestS3BucketName = infrastack-testbucket560b80bc-014d95ad
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:000000000000:stack/InfraStack/df24d1bb

✨  Total time: 39.75s

deployされているはずなので、上記のTestApiGeEndPointを叩いてみる。

$ curl http://localhost:4566/restapis/vvv23joyjg/prod/_user_request_/
{"message": "File created"}

lambdaが呼び出されてS3にファイルができていることを確認する。

$ awsd s3 ls s3://infrastack-testbucket560b80bc-014d95ad --endpoint-url=http://$LOCALSTACK_IP:4566
2022-01-16 11:41:52         20 test_20220116-114152.txt

見事deployされていることが確認できました!!やったね!

まとめ

ということで、AWS CDKとlocalstackを使って、API Gateway + Lambda + S3の簡単なIaC練習をやってみました。今回のコードはここにあげていますので、何かの参考になれば。

AWS CDKとlocalstackの組み合わせはだいぶ昔から知っていましたが、今回AWS CDK v2でもなんとなく動作することが確認できたので良かったです。

今後も練習として、localstackで色々マイクロサービスアーキテクチャを試していけたらいいなと思います。

Discussion