Monday, April 13, 2026

Alternatives for LocalStack in TestContainers with Spring Boot

Introduction

Since around 20 March 2026 the company behind LocalStack decided to not support the community edition of LocalStack anymore, only paid versions are now available. 


A lot of forks were made from that now-frozen community repository, like this one.  Will any of these become the new community supported version?

Many teams use LocalStack within TestContainers in a Spring Boot application for example. What are the alternatives? This post is about exploring those.

Note that you can also pin the version of LocalStack to the last community edition release 4.14.0, that will keep on working after 6 april 2026. But there won’t be any updates on it of course…

Two forms are emerging: either replace the whole AWS stack as much as possible with one framework, or take an separate implementation per AWS service, so: implementation A to support one specific AWS service and implementation B for another AWS service, etc.

Below I’ve split my analysis in those two forms. 



Full AWS stack replacements


Several options exist for full AWS stack replacements.

Floci

 

Floci is a free, open-source local AWS emulator which can be found at https://github.com/floci-io/floci
It is a very recently created project , but got quite some traction already.  
A week ago Floci changed into a separate organisation, see this LinkedIn post
It is not a ful drop-in replacement in TestContainers, but it comes close. Below shows what needs changing in a Kotlin Spring Boot application if you are currently using LocalStack.

Replacing LocalStack 

 

You probably have something like this currently configured with LocalStack:

@TestConfiguration

class TestConfig {

  @Bean

  fun localStackContainer(): LocalStackContainer {

    return LocalStackContainer(DockerImageName.parse("localstack/localstack:4:14:0"))

      .withServices(LocalStackContainer.Service.DYNAMODB)

  }


Basically changing it by replacing the docker image name and the environment variables should get it working already you’d think, so like this:

@TestConfiguration

class TestConfig {

@Bean

fun localStackContainer(): LocalStackContainer {

  return LocalStackContainer(DockerImageName.parse(hectorvent/floci:1.5.2).asCompatibleSubstituteFor("localstack/localstack"))

    .withEnv("AWS_ENDPOINT_URL", "http://localhost:4566")

    .withEnv("AWS_DEFAULT_REGION", "us-east-1")

    .withEnv("AWS_ACCESS_KEY_ID", "test")

    .withEnv("AWS_SECRET_ACCESS_KEY", "test")

}


But no it is not that easy. You’ll need to insert a different startup-script.
For example like this:

class FlociContainer(dockerImageName: DockerImageName) :   LocalStackContainer(dockerImageName) {


override fun containerIsStarting(containerInfo: InspectContainerResponse) {

  var shell: String

  var executable: String

  if (dockerImageName.contains("jvm")) {

    shell = "/bin/sh"

    executable = "java -jar quarkus-app/quarkus-run.jar"

  } else {

    shell = "/bin/bash"

    executable = "./application"

  }

  var command = shell + "\n" + executable + "\n"

  try {

    copyFileToContainer(Transferable.of(command, 511), STARTER_SCRIPT)

  } catch (e: Throwable) {

    logger.error("Failed to copy startup script to container: ${e.message}", e)

    throw e

  }

}

}

 

Note the logic to determine the correct command for the startup-script to use in the Docker image. Floci has a native version which has a different startup-script than the JVM version.
You’ll also need to parse for a different startup string than LocalStack has.
TestContainers has a module for LocalStack that looks for this string: ".*Ready\\.\n"
But Floci does not log that string, so one to search for is: ".*started in.*"
So you need to override that for the Floci version. 


Then the test configuration becomes this:

@TestConfiguration

class TestConfig {

@Bean

fun localStackContainer(): LocalStackContainer {

  val flociImage: DockerImageName =

  DockerImageName.parse(hectorvent/floci:1.5.2).asCompatibleSubstituteFor("localstack/localstack")

 

  return FlociContainer(flociImage)

          // Floci settings

    .withEnv("AWS_ENDPOINT_URL", "http://localhost:4566")

    .withEnv("AWS_DEFAULT_REGION", "us-east-1")

    .withEnv("AWS_ACCESS_KEY_ID", "test")

    .withEnv("AWS_SECRET_ACCESS_KEY", "test")

          // Disable services you don’t need

    .withEnv("FLOCI_SERVICES_SSM_ENABLED", "false")

    .withEnv("FLOCI_SERVICES_ELASTICSEARCH_ENABLED", "false")

           // Floci container has different log-text to look for

    .waitingFor(LogMessageWaitStrategy().withRegEx(".*started in.*").withTimes(1))

}

 

That should do it!


Building Docker JVM image of Floci and start it locally 

 

For building a Docker image for the JVM version, you can run these commands in the root directory of the Floci project: 

  1. Create the artifact (application) for the Docker image: ./mvnw clean package
  2. Build the docker image: docker build . -t floci-jvm-package:1.5.1-fix-001 -f Dockerfile.jvm-package 


To start the JVM image of Floci locally you can run this command in the root directory:

 

docker-compose -f docker-compose-fix-001.xml up

 

With this in that docker compose file:

 

services:

    floci:

        image:    floci-jvm-package:1.5.1-fix-001

        ports:

            - "4566:4566"

        volumes:

            - ./data:/app/data

            - ./init/start.d:/etc/floci/init/start.d:ro

            - ./init/stop.d:/etc/floci/init/stop.d:ro


The output will look something like this then:

Container common-adapters-floci-1    Recreated                                                                                                                                                                                                                                                                                                                          0.1s

Attaching to floci-1

floci-1    |      ______ _            ____      _____ _____

floci-1    |    |    ____| |        / __ \ / ____|_      _|

floci-1    |    | |__    | |      | |    | | |            | |   

floci-1    |    |    __| | |      | |    | | |            | |   

floci-1    |    | |        | |___| |__| | |____ _| |_

floci-1    |    |_|        |______\____/ \_____|_____|

floci-1    |

floci-1    |

floci-1    |                      Powered by Quarkus 3.32.3

floci-1    | 2026-04-11 15:51:14,012 INFO    [io.github.hectorvent.floci.lifecycle.EmulatorLifecycle] (main) === AWS Local Emulator Starting ===

floci-1    | 2026-04-11 15:51:14,012 INFO    [io.github.hectorvent.floci.lifecycle.EmulatorLifecycle] (main) Storage mode: memory

floci-1    | 2026-04-11 15:51:14,012 INFO    [io.github.hectorvent.floci.lifecycle.EmulatorLifecycle] (main) Persistent path: ./data

floci-1    | 2026-04-11 15:51:14,012 INFO    [io.github.hectorvent.floci.core.common.ServiceRegistry] (main) Enabled services: [ssm, sqs, s3, dynamodb, sns, lambda, apigateway, iam, elasticache, rds, events, scheduler, logs, monitoring, secretsmanager, apigatewayv2, kinesis, kms, cognito-idp, states, cloudformation, acm, email, es]

floci-1    | 2026-04-11 15:51:14,013 INFO    [io.github.hectorvent.floci.lifecycle.EmulatorLifecycle] (main) === AWS Local Emulator Ready ===

floci-1    | 2026-04-11 15:51:14,021 INFO    [io.quarkus] (main) floci 1.4.0 native (powered by Quarkus 3.32.3) started in 0.085s. Listening on: http://0.0.0.0:4566

floci-1    | 2026-04-11 15:51:14,021 INFO    [io.quarkus] (main) Profile prod activated.

floci-1    | 2026-04-11 15:51:14,021 INFO    [io.quarkus] (main) Installed features: [cdi, config-yaml, rest, rest-jackson, smallrye-context-propagation, vertx]


Notice also the ‘started in 0.085s’, which contains the string for which the above example FlociContainer is looking for to see if the container started successfully.


Automatically create your application queues in Floci


A fancy enhancement for when you are using Spring Boot: you can write a BeanFactoryPostProcessor which parses for @SqsListener annotations to automatically create the queues in the Floci container.

Run Integration Tests against locally running Floci


To run your tests against a locally running Floci instance directly, for example to see its logging, started up with Docker like mentioned above, you override the endpoint when creating the DynamoDBClient:

    return DynamoDbClient.builder()

          .endpointOverride(URI.create("http://localhost:4566"))

    .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(localStackContainer.accessKey, localStackContainer.secretKey)))

    .region(Region.of(localStackContainer.region))

    .build()

}


Testcontainers module for Floci

 

There’s now also a testcontainers module available for floci so you should not need all above manual changes: https://testcontainers.com/modules/floci/

I did not test this module yet.

 



Other full replacement options

  • Rust focused it seems: Rustack
  • Only 1 maintainer and runs standalone, some AWS, some GCP, some Azure services supported: CloudTwin
  • Python focused it seems: Moto: This post talks about Moto mocking and using it in unittests. Is it a mocking or emulating framework?
  • Any of the LocalStack forks.


AWS stack per-service replacement


Multiple options exist, this list might not be listing all options. 

  1. DynamoDB: 
    1. use the AWS DynamoDB Local replacement. Docker image is here. Can be used on its own, and there’s a TestContainers module for it too here.
    2. Dynalite: last update in 2020, seems to be dead. The TestContainers module seems to be gone now too
  2. S3: Minio Not supported anymore, see here.
  3. SQS: https://lib.rs/crates/tc_elasticmq. Docker image here.






Friday, October 3, 2025

Android Pixel 6 phone not all photos show up when connected via USB to computer

Introduction

I usually manually make copies of important photos on my Pixel 6 Android mobile phone via the USB-C cable. Just connect it to your computer and select in USB preferences (really strange location for these options, why is it not in USB settings...) File transfer / Android Auto.
Then the file explorer would open up and I can go to the DCIM/Camera folder and find all my photos on that phone.

But suddenly, it started around May 2025, I could only see pictures in that directory from May 2025 and newer! I could not see my pictures from before that time anymore in that directory.
On the phone itself I could still these older photos.

Solution

I found a few possible reasons, like this one, but that didn't work for me. Still couldn't find the photos from before May 2025 when connected via USB.
I did have for some vague reason Google Backup on for a few hours somewhere before that time. But switched it off again.

So what does it turn out to be: all photos from before May 2025 had moved to another directory! I for sure didn't do that - it would have taken quite a long time to move all these photos on my phone, hundreds of them.
So here's the difference I found out:

Where the photos usually are located:

So in the DCIM/Camera directory, as already mentioned in the introduction.
But all before May 2025 are now in this directory:




So in Music/DCIM/Camera! So they moved! But only those earlier than May 1 2025!

Luckily that directory is also accessible in the file explorer when connected via USB. My suspicion is that while I had the backup on, it moved those pictures to that other directory...

You can find the exact location of a picture in the Pixel Photos app by selecting the picture on your phone and swiping up. Then you see what you see in the above pictures, including the directory where it is stored on the device.




Monday, April 7, 2025

Spring Boot 3 serverside forwarding

Introduction

Sometimes you want to forward to another URL on the serverside in Spring Boot 3. 


The example in this blogpost is for forwarding to the Swagger JSON open-api-specification OAS /api-docs resource listing. You might need this when you want to protect that URL via Spring authentication, so it has to first come in to a controller, and only then forward it.

Solution

Below a new controller ApiController is created, with as endpoint GET /open-api. That has bearer authentication on it. And redirects to /apidocs/api-definition. For details on what api-definitions i.e grouped Open API definitions are, see here.

ModelAndView

Using a ModelAndView from Spring MVC:

  @RestController
  @SecurityRequirement(name = "bearerAuth")
  public class ApiController() {
    @Operation(
      summary = "Gets the OpenApi specification of the API",
    )
    @GetMapping("/open-api", produces = ["application/json"])
    @SecurityRequirement(name = AUTHORIZATION)
    fun getOpenApiSpecification(): ModelAndView {
      return ModelAndView("forward:/api-docs/api-definition")
    }

To have this show up in Swagger, also set in application.yml:

  springdoc:
    model-and-view-allowed: true

Servlet context

Using the servlet context, so you don't need a ModelAndView:

  @GetMapping("/open-api", produces = ["application/json"])
  @ResponseStatus(HttpStatus.OK)
  @SecurityRequirement(name = AUTHORIZATION)
  fun getOpenApiSpecification(request: HttpServletRequest, response: HttpServletResponse) {
    request.session.servletContext.getRequestDispatcher("/api-docs/api-definition").forward(request, response)
  }

This dependency is then also needed: 
  <dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.1.0</version>
  </dependency










Wednesday, April 2, 2025

How to mock Retrofit2 response Call in a unittest

Introduction

To mock a Retrofit2 call which returns Call<Void?> in the interface client like this:

    @POST("/events")

    fun registerEvent(@Body requestDto: EventRequestDto): Call<Void?>

is not super-obvious, since it can show this error when invoked:

    Cannot invoke "retrofit2.Call.request()" because "call$iv" is null

    java.lang.NullPointerException: Cannot invoke "retrofit2.Call.request()" because "call$iv" is null

Solution

    val mockedResponseRegisterEvent: Call<Void?> = retrofit2.mock.Calls.response(null)
    every { retrofitEventServiceClient.registerEvent(any()) } returns mockedResponseRegisterEvent

Wednesday, February 14, 2024

Kotlin: how to mock a static Companion method using MockK

Introduction

How do you mock a static companion method in Kotlin using MockK?

I wanted to mock this (static) method in a companion object in Kotlin in class MyClass:

  companion object {
    fun isNegativeAndFromSavingsAccount(amount: BigDecimal, accountType: accountType) = amount < BigDecimal.ZERO && accountType == AccountType.SAVINGS
  }

 Trying it with a regular 'every' like this doesn't work:

      every { MyClass.isNegativeAndFromSavingsAccount(any(), any()) } returns false

Note it does compile fine!
But when running the test, you'll get this error:

    io.mockk.MockKException: Failed matching mocking signature for left matchers: [any(), any()]
    at io.mockk.impl.recording.SignatureMatcherDetector.detect(SignatureMatcherDetector.kt:97)

Setup:

Solution

This is the way it does work:

import io.mockk.mockkObject

    mockkObject(MyClass.Companion) {
      every { MyClass.
isNegativeAndFromSavingsAccount(any(), any()) } returns false
    }

Note this did not work, got the same error:

    mockkObject(MyClass::class)
    every { MyClass.
isNegativeAndFromSavingsAccount(any(), any()) } returns false

I found several posts, but none of them gave a clear answer and/or were using some older version of MockK. E.g: https://github.com/mockk/mockk/issues/61
and this StackOverflow post.

Some more examples and variants of solutions can be found here, e.g when using @JvmStatic.

Tuesday, December 26, 2023

OWASP DependencyCheck returns 403 Forbidden accessing NVD API using API key

Introduction

Recently, the NVD (National Vulnerability Database) which the Owasp dependency check plugin uses to get its data from to check for vulnerabilities, has introduced the use of an API key. That's for them to better control access and throttling - imagine how many companies and organizations are using that API, each time a dependency check build is performed. Especially those that don't cache the NVD database and at each run retrieve it again. And be aware: "... previous versions of dependency-check utilize the NVD data feeds which will be deprecated on Dec 15th, 2023. Versions earlier then 9.0.0 are no longer supported and could fail to work after Dec 15th, 2023."

But this introduction doesn't go without some hiccups. For example it is possible to still get HTTP 403 Forbidden responses, even while you have a valid key. Here's my research while trying to fix it.

Setup:

  • Gradle 7.x
  • Dependency Check v9.0.6 (issue applies at least for versions > 8.4.3)
  • Configuration:

    dependencyCheck {
        failBuildOnCVSS = 6
        failOnError = true
        suppressionFile = '/bamboo/owasp/suppressions.xml'
        nvd.apiKey = '
    <yourkey>'
    }

    You can also set it dynamically via an environment variable like this:

    dependencyCheck {
      nvd {
        apiKey = System.getProperty("ENV_NVD_API_KEY")
      }
    }

  • Via commandline you can invoke it like this:

    ./gradlew dependencyCheckAggregate -DENV_NVD_API_KEY=<yourkey>

 

Solution

First you should check if your API key is valid by execution this command:

curl -H "Accept: application/json" -H "apiKey: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -v https://services.nvd.nist.gov/rest/json/cves/2.0\?cpeName\=cpe:2.3:o:microsoft:windows_10:1607:\*:\*:\*:\*:\*:\*:\*
 

(where xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx is your NVD API key)

That should return JSON (and not a 404). Now you know your API key is valid.
 

Some have some success with setting the delay longer:

    nvd {
        apiKey = System.getProperty("ENV_NVD_API_KEY")
        delay = 6000 // milliseconds, default is 2000 with API key, 8000 without
    }

Commandline version:

--nvdDelay 6000
 

You can also increase the validForHours option, but that doesn't work if during you construct completely new Docker containers each build - you lose that history.

All NVD options you can pass to DependencyCheck are found here.

But currently (27 December 2023) all the above efforts don't always fix the problem of the 403. Sometimes it works for a while, but then not again. If you  build many projects in your company at about the same time, you still have a chance of getting throttled of course.

The best solution is to create a local cache so you are less dependent on NVID API calls (and thus the throttling).

 

Other causes mentioned

  • Being behind a proxy with your whole company, see https://github.com/jeremylong/DependencyCheck/issues/6127 and "If you have multiple builds happening at the same time - using the same API key you could hit the NVD rate limiting threshold. Ideally, in an environment with multiple builds you would implement some sort of caching strategy". See: https://github.com/jeremylong/DependencyCheck/issues/6195
  • Use --data argument to control cache location.
  • It appears the NVD has put a temporary block on all requests that use a virtualMatchString of "cpe:2.3:a" - outstanding issue.


Misc

 

Saturday, November 4, 2023

MapStruct library: setting a field with an expression to a ZonedDateTime

Introduction

Setup:

- Mapstruct 1.5.5
- Kotlin 1.8
- IntelliJ 2023.2.3
 

 

For a mapping between two classes, the source class did not have the mandatory (non-null, Kotlin!) target class field created. And I wanted to fill it with the current ZonedDateTime class, with the timezone of Amsterdam/Europe. And that specific ZoneId is a constant in my Application.kt class named MY_DEFAULT_TIME_ZONE.

Solution

So I looked at the expression field in @Mapping found here.
I got this solution working quite fast with: 

@Mapping(target = "created", expression = "java(ZonedDateTime.now())"). 

But as you see, the ZoneId constant is still missing.

Potential other solutions

I had to try quite a few things to get that working, because the class ZoneId was not getting recognized in the generated implementation MapStruct mapper.
In the end this worked:

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = [ZoneId::class, Application::class])
interface MyMapper {

  @Mapping(target = "created", expression = "java(ZonedDateTime.now(my.package.Application.MY_DEFAULT_TIME_ZONE))")
  fun myDtoToResponseDto(myDto: MyDto): ResponseDto
  ...
}
Note the imports field to have the class ZoneId and the constant available (imported) in the implementation class, generated by MapStruct.

In the Application.kt you then have to make the MY_DEFAULT_TIME_ZONE constant available to Java, since that's what MapStruct uses as language:

Application.kt
{
  companion object {

    @JvmField
    val MY_DEFAULT_TIME_ZONE: ZoneId = ZoneId.of("Europe/Amsterdam")
    ...
}

I also tried this: 

@Mapping(target = "created", source = ".", qualifiedByName = ["getValue"]) 

with a function:

@Named(value = "getValue")
fun getValue(myDto: MyDto): ZonedDateTime {
  return ZonedDateTime.now(MY_DEFAULT_TIME_ZONE)
}

The advantage of this solution is that you can use Kotlin code and you don't have to wait and see if your expression has the correct syntax and will compile.
But then I got this error: ZonedDateTime does not have an accessible constructor. I also tried to wrap the field created in a small class, but that didn't work either (could be me :)
See this and this for more details on how that should work.

I also tried with the @JvmDefault annotation, but that is deprecated + it requires you to use the -Xjvm-default property, which I couldn't get to work in IntelliJ with Gradle.
And it is not always guaranteed to work, see here and here and here:

I'm definitely still a beginner in using MapStruct. So probably one of the other methods could work too... Any tips are welcome :)

Wednesday, September 27, 2023

SpringDoc OpenAPI Swagger generated Swagger API shows incorrect class with same name

Introduction

When you have multiple classes with the same name in your classpath, SpringDoc with Swagger API annotations potentially picks the wrong class with the same name when generating the Swagger UI documentation.


Suppose you have these classes:

  • org.example.BookDto
  • org.example.domain.BookDto
     

And you specified your endpoint like this, where you want to have it use org.example.BookDto:

  @Operation(summary = "Get a list of books for a given shop")
  @ApiResponses(
    value = [
      ApiResponse(
        responseCode = "200",
        description = "A list of books",
        content = [Content(mediaType = "application/json",
                    array = ArraySchema(schema = Schema(implementation = BookDto::class)))]
      )
    ]
  )
  @GetMapping("/books/{shopId}")
  fun getBooksByShopId(
    @Parameter(description = "Shop to search for")
    @PathVariable shopId: Long
  ): List<BookDto> {
    return bookService.getBooksByShopId(shopId)
      .map { BooksMapper.mapDto(it) }
  }

Then whatever it finds first on the classpath will be visible in https://localhost:8080/swagger-ui.html. Not necessarily the class you meant, it might pick org.example.domain.BookDto.  

Setup:

  • Spring Boot 3
  • Kotlin 1.8
  • Springdoc OpenAPI 2.2.0
     

Solution

Several solutions exist:

Solution 1

Specify in your application.yml:

springdoc:
 use-fqn: true

 

Disadvantage: the Swagger documentation in the swagger-ui.html endpoint has then the fully specified package classpath + classname in it. Looks ugly. 

Solution 2

Setting it in the @Bean configuration:

import io.swagger.v3.core.jackson.TypeNameResolver
  @Bean
  fun openAPI(): OpenAPI? {

    TypeNameResolver.std.setUseFqn(true)
    return OpenAPI()
      .addServersItem(Server().url("/"))
      .info(
        Info().title("Books Microservice")
          .description("The Books Microservice")
          .version("v1")
      )
      .externalDocs(
        ExternalDocumentation()
          .description("Books Microservice documentation")
          .url("https://github.com/myproject/README.md")
      )
  }

Disadvantage: also in this solution the Swagger documentation in the swagger-ui.html endpoint has then the fully specified package classpath + classname in it. Looks ugly.

Solution 3

You can create your own ModelConverters, but that is much more work. Examples here:  https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Extensions#extending-core-resolver and https://groups.google.com/g/swagger-swaggersocket/c/kKM546QXGY0

Solution 4

Make sure for each endpoint you specify the response class with full class package path:

@Operation(summary = "Get a list of books for a given shop")
  @ApiResponses(
    value = [
      ApiResponse(
        responseCode = "200",
        description = "A list of books",
        content = [Content(mediaType = "application/json",
                    array = ArraySchema(schema = Schema(implementation =
org.example.BookDto::class)))]
      )
    ]
  )
  @GetMapping("/books/{shopId}")
  fun getBooksByShopId(
    @Parameter(description = "Shop to search for")
    @PathVariable shopId: Long
  ): List<BookDto> {
    return bookService.getBooksByShopId(shopId)
      .map { BooksMapper.mapDto(it) }
  }

 See the bold Schema implementation value for what changed.


 

 

Wednesday, August 23, 2023

Unknown application error occurred Runtime.Unknown - Startup issue AWS Serverless Lambda

Introduction

Trying to invoke a deployed AWS Serverless Lambda on AWS, I was getting this error CloudWatch when trying to invoke the lambda via an SQS event, published by another service in my landscape:
 
2023-08-15T15:20:44.047+02:00 START RequestId: ab924ff5-236c-5b09-8a29-12a0b9447e41 Version: $LATEST
2023-08-15T15:20:45.223+02:00 Unknown application error occurred
  Runtime.Unknown
  Unknown application error occurred Runtime.Unknown
2023-08-15T15:20:45.223+02:00 END RequestId: ab924ff5-236c-5b09-8a29-12a0b9447e41

 

That's all. No more details. Nothing appeared in Datadog to which my CW logging is forwarded to. But the lambda ran fine when running it locally in IntelliJ using the SAM AWS Toolkit, with me logged in with my IAM role.
Adding logging or a try/catch wouldn't do anything, since this error appears already before the lambda even gets invoked.
 
Setup:
  • AWS Serverless Lambda
  • IAM
  • IntelliJ AWS Toolkit
  • Kotlin 1.8.10
  • CloudWatch
  • Datadog
  • AWS Parameter Store
  • KMS
  • SSM
  • SQS
     
 

Solution

Then I tried to trigger the lambda via the AWS console by manually creating the SQS event and sending it on the SQS queue the lambda is listening to. There I did get the details of the error shown:

{
  "errorMessage": "User: arn:aws:sts::100004:assumed-role/my-lambda-role-acc/my-lambda is not authorized to perform: ssm:GetParameter on resource: arn:aws:ssm:eu-west-1:
100004:parameter/abc/apikey because no identity-based policy allows the ssm:GetParameter action (Service: AWSSimpleSystemsManagement; Status Code: 400; Error Code: AccessDeniedException; Request ID: 657c62f2-3527-42e0-8ee4-xxxxxxxx; Proxy: null)",
  "errorType": "com.amazonaws.services.simplesystemsmanagement.model.AWSSimpleSystemsManagementException"
 
See this screenshot: 

 
 
The reason it worked locally is probably because there I'm logged in with a different IAM account (with more permissions) than when the lambda is deployed in the AWS cloud.

Then after fixing that by adding the path abc/apikey to the key as resource, I got this error:
{
  "errorMessage": "User: arn:aws:sts::
100004:assumed-role/my-lambda-role-acc/my-lambda
is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:eu-west-1:
100004:key/ff841b70-5038-6f0b-8621-xxxxxx because no identity-based policy
allows the kms:Decrypt action (Service: AWSKMS; Status Code: 400; Error Code: AccessDeniedException; Request ID: aaa5a8d0-d26e-5051-7ac0-xxxxxxxx; Proxy: null)
(Service: AWSSimpleSystemsManagement; Status Code: 400; Error Code: AccessDeniedException; Request ID: f807b8d7-826e-4d4c-9b5c-xxxxxxx; Proxy: null)",
  "errorType": "com.amazonaws.services.simplesystemsmanagement.model.AWSSimpleSystemsManagementException"
}


So the KMS decrypt is not allowed (no permission for) on that specific AWS Parameter Store entry abc/apikey.

The fix here was to add the action for the correct resource, see the items in bold:

  statement {
    sid    = "AllowReadingParameterStoreParameters"
    effect = "Allow"

    actions = [
      "ssm:DescribeParameters",
      "ssm:GetParameter",
      "kms:Decrypt"          
    ]

    resources = [
      "arn:aws:ssm:eu-west-1:100004:parameter/abc/apikey",
      "arn:aws:kms:
eu-west-1:100004:key/*
    ]
  }

Note that the error gave away already a little on how to name that resource. Be aware that this way you potentially give more Decrypt access than you want...

Misc:
Other tips to try if you ever have this Runtime.Unknown error (but did not try): Instrumenting Java code in AWS Lambda.
And some more generic tips for troubleshooting during/before invocation.
And while executing: https://docs.aws.amazon.com/lambda/latest/dg/troubleshooting-execution.html