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. Use docker 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