by @maxthom
This repo aims to showcase the usage of Localstack and AWS CDK to address specific integration challenges regarding local development where the end target is the AWS platform.
The following tenets were in mind (unless you know better ones):
- Everything is code and is stored at the same location - Every single component of the application we're building has a code representation to ensure quality through test, reliability through reproduction and security through auditability.
- Our implementation is frugal - We use the right tool for the right job and we decrease the number of lines of code to maximize productivity, ease debug and ensure high quality.
- We trust the code - Local code is exactly the code sent and deployed in the cloud.
- We test in production-close conditions - Local development is representative of the AWS cloud and we can add / remove test AWS accounts without interferring with CI/CD pipelines.
- Docker
- (Optional) Docker Desktop
- Localstack
- (Optional) Localstack Desktop
- AWS CLI
- Localstack AWSLocal
- AWS CDK
- Localstack CDKLocal
- (global) Typescript
- (dependency) esbuild
brew install docker
docker --version
(Optional) install Docker Desktop
brew install localstack/tap/localstack-cli
localstack --version
(Optional) Install LocalStack Desktop
npm install -g aws-cdk-local aws-cdk
python3 -m pip --version
python3 -m pip install awscli awscli-local
awslocal --version
localstack start -d --no-banner
Where:
- d is for Daemon mode
- no banner remove the ASCII style banner (might cause trouble with Windows)
localstack stop
localstack config show
mkdir cdk
cd cdk
cdklocal init --language typescript
cd cdk
cdklocal bootstrap
awslocal s3 ls
cdk deploy --require-approval never
Where:
- require approval never skips approval prompts
The following takes all the benefits from cdk and localstack to have one unique stack deployed both locally and remotely. This means the services requried by the architecture is lowered by the actual list of LocalStack supported service. For non exotic usage, it should cover most of the cases.
-
Edit
cdk/lib/cdk-stack.ts
-
Add the creation of the Dynamo Table as follow (or uncomment the code):
const todoTable = new Table(this, "TodoTable", {
tableName: "Todo",
partitionKey: { name: "id", type: AttributeType.STRING },
})
- Synthetize the Cloudformation template:
# cdklocal synth
- Deploy
cdklocal deploy --require-approval never
Backend code is supported by Typescript and esbuild. Source code is located in the src
directory and has different directories for each lambda. Shared code is at the root of src
.
src
--> <lambda name>
--> index.ts
--> <other resources>
ddb.ts
<other resources>
To start to develop:
$ cd lambda
$ lambda > npm start
It kicks off a watch process to compile the typescript if the content of the file change.
Using the Hot reload
of Localstack, the code is then automatically (and magically) uploaded in the lambda. It allows you to developp locally and have a seamless developer experience, faster than Lambda's web editor.
Note: You need one hot reload bucket for each lambda (using the same does not work)
Frontend does not need to be in the local CDK infrastructure as a developer can develop locally SPA without all the stack.
To start to develop:
$ npm run dev
-
Check that the table is created
$ awslocal dynamodb list-tables
-
Download the software
-
Install it & run it
-
Select
Operation Builder
>Add Connection
and fill theDynamoDB local
form -
Change the port to
4566
//TOCHECK -
Browse the local tables
$ curl -X POST \
'http://<XXXXXXXX>.lambda-url.us-east-1.localhost.localstack.cloud:4566/' \
-H 'Content-Type: application/json' \
-d '{"num1": "10", "num2": "10"}'
$ awslocal logs tail /aws/lambda/Todo --since 1m
Symptoms: in the browser, a RestAPI call response status is "502" with CORS reason.
Debug steps:
- Brower shows CORS errors but it can be a classic regular error such as 4xx or 5xx. If the method is GET, open the link in the browser. You would have the main reason of the CORS issue: either a 404 or a 500. For example, *Internal errorraises a
- Check the following:
- API has the API resource definition in the CDK stack code
- CDK has been deployed
- Lambda resource is found and entered (console.log at the top of the handler)
- Lambda resource is entered and you enter the right method/path branch (console.log in the right portion of the code)
Build is pushed in the dist
directory, so deployment is about pushing the lambda code through CDK.
Build is pushed in the .next
directory, so the deployment is about pushing the SPA code through CDK.
Considering the architecture of your project, you might have some differences between the infrastructure described locally and what you might have in the AWS platform. Localstack does not cover all the AWS Services and the coverage is available here: https://docs.localstack.cloud/user-guide/aws/feature-coverage/.
It implies that you'll have to add some logic in the CDK code to split both architectures.
Basically, we use the TS imports to isolate parts of the code in different sections. Regarding the documentation of CDK on building a stack, a stack is defined by a scope and all constructs can be attached to this scope with construct's constructor function signature.
It means, you can leverage function in Typescript building Constructs, Array of Constructs or externalize the code for reuse.
In cdk/lib/cdk-stack.ts
, you can add the following:
const getBucket = (scope: Construct, bucketName: string) => {
return new s3.Bucket(scope, 'Bucket', {
bucketName,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
versioned: true,
removalPolicy: RemovalPolicy.RETAIN,
})
}
and use it in the code of the stack's constructor:
...
const myBucket = getBucket("my-bucket-32")
...
Following the same principle, you can factorize your code to minimize its size, increase readability and reduce time to debug (less lines of code = easier to manage).
Let's say you have a list of lambda and you want one bucket by lambda, you can do:
const getLambdas = (scope: Construct)=>{
const lambdaList : Function[] = ...
const bucketList : any = {}
lambdaList.map((lambda:Function)=>{
const bucket = getBucket(scope, lambda.functionName)
bucketList[lambda.functionName] = bucket
})
return {lambdaList, bucketList}
}
The last part is to reorganize the code so we have functional blocks corresponding to a specific intent. To do so, apply the following architecture tenet: "for each single feature, we havee one and only one technical solution", whih basically means "we have only one file for every single purpose".
Following the same example, we can split our code in several pieces.
- We move the getBucket function to another file called
storage.ts
. And we prefix the function byexport
so the code will be reacheable from animport
elsewhere. - We move the lambda code to another file called
compute.ts
, we package it in a function, similar togetBucket
and we do the same prefix to expose our function. As the lambda code usegetBucket
, we import it at the top of the file. - We remove all the previous code in the stack and we import the
getLambdas
function to use it.
Proceeding like this has several benefits:
- the import system guarantees that you use only what is useful.
- the functions are reusable and also composable (see CDK documentation)
- the approach is functional, by big set of features, so you can easily navigate the code of your stack and identify resources easily.
This is great but if your infrastructure becomes huge, you'll face two main challenges:
- CDK generates resources for you through the use of construct, and in certain cases, you won't need constrcut abstraction (L3).
- CDK generates resources, so you'll quickly reach the famous limit of 500 resources by AWS Cloud Formation.
To avoid those, the architecture tenet above helps your to split in the right way:
- split your stack in several Nested Stacks
- split by big technical mandatory features such as Network, Storage, Compute, Security and so on. It will result on resources set for the whole application and required for the built of the application.
- then for the application, if it's too big, you'll get to split in several nested stacks. You have the choice on two approaches:
- There's a natural split by technology or purpose : frontend and backend for instances.
- There's a functional split by domain or feature : users and registrations for example (including a mini-stack for each of those solutions)
The benefit of using Nasted Stacks
is important:
- For the delivery, Cloud Formation delivers only what needs to be changed, so it's quicker and you limit your blast radius in case of issue.
- For reversibility, if you have to roll out for any reason, you'll roll out only the corresponding nested stack, so the time to retrieve normal operations is reduced.
And it comes with several constraints:
- Reorganizing the code implies a recompute for CDK of the logical ID. The logical ID is used to identify resource in CloudFormation and it's generated by CDK. Moving code from one file to another file will change the generated logical id and then create a new resource.
- The granularity comes with a cost in terms of time as you have to set up the initial file split.
Please create a new GitHub issue for any feature requests, bugs, or documentation improvements.
Where possible, please also submit a pull request for the change.
This sample code is made available under the MIT-0 license. See the LICENSE file.