LocalStack
I recently needed to be able to test a lambda function locally. Whilst its possible to do this in AWS, the feedback loop was frustratingly long. To speed this up, I decided to leverage LocalStack. This was a fairly interesting exercise so I thought I would share my notes around getting this set-up in the hope that it helps someone!
You can find the example code referenced throughout this guide here. It’s a simple Spring Cloud Function that connects to AWS Parameter Store and uploads a dummy file to S3. The example app uses Java 11 and Spring Boot 2.X - as that’s the environment I needed to work in 😀
What is LocalStack?
It provides a Docker runtime that emulates a cloud service provider. For the purpose of this guide, it simply provides the capability of deploying and running applications built for AWS locally. For a more detailed understanding of LocalStack and its capabilities, read more on the LocalStack homepage.
Requirements
- Docker (or suitable alternative e.g. OrbStack, Colima etc.)
- Docker Compose
- Python 3.X
- AWS local CLI helper
pip install awscli-local
- An env var that sets the default region for awslocal & LocalStack e.g.
export AWS_DEFAULT_REGION=us-east-1
Configuration
Add the following docker-compose.yml
to your local repository:
version: '3.9'
services:
localstack:
container_name: localstack_main
image: localstack/localstack:0.14
ports:
- "4566-4620:4566-4620"
environment:
- DEBUG=1
- SERVICES=s3,lambda,ssm
- LAMBDA_EXECUTOR=docker-reuse
- LAMBDA_DOCKER_NETWORK=localstack_development
networks:
- development
volumes:
- "${TEMPDIR:-/tmp/localstack}:/tmp/localstack"
- "/var/run/docker.sock:/var/run/docker.sock"
networks:
development:
This creates the LocalStack container in Docker with the capability to utilise S3, Lambda & SSM (Parameter Store). We start the service in debug mode (can be removed once things are working as expected).
NB:
LAMBDA_DOCKER_NETWORK
: value depends on the name of the network created by docker-compose. Usedocker network ls
to find the appropriate value (assuming you have previously started the service).
NB: Newer versions of the LocalStack docker image exist - you may wish to use them instead of the one used in this guide (but there is no guarantee subsequent commands will work as expected).
Usage
Build
The sample project shows how we can utilise Spring Cloud Functions as a lightweight alternative to SpringBoot designed for execution in serverless frameworks (such as AWS Lamba). This guide does not go into any detail about Spring Cloud Functions - maybe that’s a topic for another day!
Prepare
To generate the necessary files for deploying into LocalStack run mvn clean package
from the project root.
Deploy
Once the jar file is generated, it can be deployed into LocalStack using the following command (assuming that it is being executed from the project root directory):
awslocal lambda create-function --function-name <name> --zip-file fileb://./dist/<jar_name>.jar --handler <handler_path> --runtime java11 --role arn:aws:iam::000000000000:role/lambda-ex
As an example:
awslocal lambda create-function --function-name test-lambda --zip-file fileb://./target/localstack-1.0-SNAPSHOT.jar --handler org.springframework.cloud.function.adapter.aws.FunctionInvoker --runtime java11 --role arn:aws:iam::000000000000:role/lambda-ex --environment Variables={SPRING_PROFILES_ACTIVE=local}
NB: The above example command sets the value of
SPRING_PROFILES_ACTIVE
, this will be used subsequently when interacting with AWS Parameter Store.
You should see output similar to the below:
{
"FunctionName": "test-lambda",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:test-lambda",
"Runtime": "java11",
"Role": "arn:aws:iam::000000000000:role/lambda-ex",
"Handler": "org.springframework.cloud.function.adapter.aws.FunctionInvoker",
"CodeSize": 47028997,
"Description": "",
"Timeout": 3,
"LastModified": "2024-02-09T07:08:54.921+0000",
"CodeSha256": "P7dktHX9KGkBMDe6TXmsiYWJh/JNGFsIKfKC0pPfDdo=",
"Version": "$LATEST",
"VpcConfig": {},
"Environment": {
"Variables": {
"SPRING_PROFILES_ACTIVE": "local"
}
},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "2e6a46d6-b581-4a75-999b-28339c89a211",
"State": "Active",
"LastUpdateStatus": "Successful",
"PackageType": "Zip",
"Architectures": [
"x86_64"
]
}
Execute
To interact with a deployed lambda we need to invoke it. This can again be done via the CLI:
awslocal lambda invoke --function-name <function_name> out.log --log-type Tail --query 'LogResult' --output text | base64 -d
The above invokes a given lambda, receives the result, stores the response in a file, base64 decodes the response and outputs it to the terminal. If the lambda requires an input event this can also be passed:
awslocal lambda invoke --function-name <function_name> --cli-binary-format raw-in-base64-out --payload '"json"' out.log --log-type Tail --query 'LogResult' --output text | base64 -d
If you are following this guide, you can invoke the test lambda using the following command:
awslocal lambda invoke --function-name test-lambda out.log --log-type Tail --query 'LogResult' --output text | base64 -d
However, the initial invocation doesn’t work 😱 and produces something like the following error:
at org.springframework.context.event.SimpleApplication...
localstack_main | 2024-02-11T12:05:29.301:INFO:localstack.services.awslambda.lambda_executors: writing log to file '/tmp/localstack/lambda_kvt480cy.log'
localstack_main | 2024-02-11T12:05:29.311:INFO:localstack.services.awslambda.lambda_api: Error executing Lambda function arn:aws:lambda:us-east-1:000000000000:function:test-lambda: Lambda process returned with error. Result: {"errorType":"java.lang.IllegalStateException","errorMessage":"Unable to load config data from 'aws-parameterstore:'"}.
LocalStack Setup
Our project fails to execute as we have a number of AWS dependencies we need to configure.
S3
As part of the example app, we attempt to upload a file to S3. This code will fail unless such a bucket exists. To create the bucket, we can execute the following command:
awslocal s3api create-bucket --bucket localstack-local-logs
NB: The bucket must have this exact name for the example code to work!
Param Store
When we write any application we typically have secrets that need to be accessed in a deployed service. Its good practice to not store these secrets alongside the code. To achieve this via AWS we have a number of possible options.
In this guide we use AWS Parameter Store to achieve this. For more detailed documentation on how this is achieved in Spring Cloud see here.
Usage
Add the Spring Cloud dependency to integrate with AWS Param Store.
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-starter-aws-parameter-store-config</artifactId>
<version>${paramstore.version}</version>
</dependency>
To enable local access to Param Store update your application.yml
file:
spring:
application:
name: mor-health
config:
import: "aws-parameterstore:"
The addition of import: "aws-parameterstore:"
to the config triggers the Parameter Store bootstrap process (i.e. attempt to connect to SSM and retrieve stored parameters).
Additionally, we need to create a bootstrap.yml
(default file used for production) and bootstrap-local.yml
(local development bootstrap file for use in LocalStack) with:
aws:
paramstore:
prefix: /ash
profileSeparator: /
region: eu-west-1
enabled: true
fail-fast: true
default-context: localstack
And
aws:
paramstore:
region: null
endpoint: "http://${LOCALSTACK_HOSTNAME}:4566"
The bootstrap.yml
file configures the default way in which we wish to connect to Parameter Store. This includes things such as the AWS region that we will use to store parameters, how we will namespace our parameters (separator, prefix, app name) etc. For example, if connected successfully, our application will attempt to load values from eu-west-1
at the following path:
<separator><prefix><separator><app_name><separator><env><separator><variable>
E.g.
/ash/localstack/local/some_variable
Once connected to Parameter Store, the application will attempt to resolve variables at start-up annotated with @Value(${param})
annotations or defined in ConfigurationProperty classes.
@Value("${SOME_VARIABLE}")
private String someVariable;
@Value
@NonFinal
@ConstructorBinding
@ConfigurationProperties(prefix = "com.some.example")
public class SomeProperties {
String someVariable;
}
com:
some:
example:
someVariable: ${SOME_VARIABLE}
Create parameters in SSM
To be able to successfully execute the example function, we need to use the awslocal
cli:
awslocal ssm put-parameter --name "/ash/localstack/local/ENV_ID" --value "local" --type String
The above uses the local
namespace as we are deploying the lambda locally (as specified in the deploy command earlier --environment Variables={SPRING_PROFILES_ACTIVE=local}
. The environment value (local
) is inferred from the active Spring profile.
Verification
Now we have successfully setup the necessary prerequisites in LocalStack, we should finally be able to successfully invoke our lambda. Running awslocal lambda invoke
function again should produce output similar to:
[2024-02-11T20:41:39.883Z] (aws:797d8c33-c518-1d54-aa04-fafed2f33871) (cid:NO_CORRELATION_ID) DEBUG com.amazonaws.requestId - x-amzn-RequestId: MMIFZ2R6JHKEMXGM2C7MTIOA5JN9GHUAOC72T3H3QBPU8NTR4013
[2024-02-11T20:41:39.883Z] (aws:797d8c33-c518-1d54-aa04-fafed2f33871) (cid:NO_CORRELATION_ID) DEBUG com.amazonaws.requestId - AWS Extended Request ID: MzRISOwyjmnupD9B476E35B2B5F717/JypPGXLh0OVFGcJaaO3KW/hRAqKOpIEEp
[2024-02-11T20:41:39.886Z] (aws:797d8c33-c518-1d54-aa04-fafed2f33871) (cid:NO_CORRELATION_ID) WARN com.amazonaws.util.Base64 - JAXB is unavailable. Will fallback to SDK implementation which may be less performant.If you are using Java 9+, you will need to include javax.xml.bind:jaxb-api as a dependency.
[2024-02-11T20:41:39.887Z] (aws:797d8c33-c518-1d54-aa04-fafed2f33871) (cid:NO_CORRELATION_ID) INFO com.ash.functions.ExampleFunction - Finished function%
We can verify that the lambda executed as desired by querying the S3 bucket created earlier. To list the contents of the bucket we simply issue the following command:
awslocal s3api list-objects --bucket localstack-local-logs
Which will print something like:
{
"Contents": [
{
"Key": "events-local-2024-02-11T20:41:00Z.log",
"LastModified": "2024-02-11T20:41:39.000Z",
"ETag": "\"9d68c7d07c1e77288f1e07b5808e661f\"",
"Size": 33,
"StorageClass": "STANDARD",
"Owner": {
"DisplayName": "webfile",
"ID": "75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a"
}
}
],
"RequestCharged": null
}
Using this response, we can simply download the file locally to check it contains the desired data:
awslocal s3api get-object --bucket localstack-local-logs --key events-local-2024-02-11T20:41:00Z.log events.log
Which, if successful, should look like:
[{"detail":"name, description!"}]
Cleanup
If we wish to delete a deployed lambda, e.g. as it didn’t execute as expected, we can issue the following command:
awslocal lambda delete-function --function-name <function_name>
Troubleshooting
Truncated response
When executing a function in LocalStack it doesn’t always return the expected response. To resolve this, simply execute the function again!
Debugging
LocalStack can be started with the debug environment variable enabled. This will provide more detailed information when attempting to debug a deployment issue. You can view LocalStack logs by tailing the container:
docker-compose logs --tail=all --follow localstack
Detailed logs
As we are using Spring Cloud, we have access to configure the slf4j
logger to obtain more detailed logs. To achieve this, update the applications logback.xml
config file to contain a file appender:
<property name="LOG_DIR" value="/tmp" />
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${LOG_DIR}/out.log</file>
<append>true</append>
<encoder>
<pattern>%-4relative [%thread] %-5level %logger{35} - %msg%n</pattern>
</encoder>
</appender>
And set the root appender to use the file logger with a debug
log level:
<root level="debug">
<appender-ref ref="FILE"/>
</root>
Its then possible to obtain detailed logs from the docker container by opening a terminal and exec’ing into it:
docker exec -it localstack_main_lambda_arn_aws_lambda_us-east-1_000000000000_function_test-lambda bash
cat /tmp/out.log