Upgrading to Spring Cloud AWS 3: a bootiful case study
A little history lesson
Long before Spring Cloud existed as an umbrella project for cloud-related Spring support, and even before Spring Boot became the default way to build Spring applications, some developers in the Spring community started a project called “Spring Cloud AWS”. It provided abstractions in a typical Spring-fashion for working with several services provided by AWS, like S3, SQS, SNS and others: templates, listener containers, resource abstractions, etc.
I think the original lead was Agim Emruli, who also worked for SpringSource in Germany back in the days.
For a while, Spring Cloud AWS was part of the Spring Cloud release train when that umbrella project was started. However, since the Spring Cloud AWS project wasn’t maintained by SpringSource/VMware/Pivotal it was decided that it should follow its own release schedule.
As is typical for non-commercial open-source projects, the original contributors to the project moved on to other things, and the project’s development stagnated.
I started to take an interest in this project ca. 6 years ago when we started to develop an integration platform for Nederlandse Loterij that runs on AWS. We needed to integrate with SQS, a point-to-point messaging solution provided by AWS, and I really did not want to write code to continuously poll queues myself in order to receive messages: having worked with Spring’s JMS and AMQP support, I was looking for something similar to that.
Spring Cloud AWS provided exactly what I wanted, so that’s what I started using.
I also implemented support for using the AWS Parameter Store as a Spring property source, which I contributed to the project back in the days when you still needed a Spring Cloud bootstrapping context for external property sources.
Around that same time, a new team of volunteers stood up to revive the project, under the lead of Maciej Walkowiak. Initially they did a lot of cleanup, added Spring Boot support in the form of autoconfiguration, etc. and released this as version 2.x (I think 2.3 was their first release) with a new website. They also ported things like the external properties support over to the new Boot spring.config.import mechanism, but to really bring the project on par with recent developments some major rework was in order.
This work led to an initial milestone of the 3.0 version of the project in June 2022, and a final release in May 2023.
Upgrading from 2.4 to 3.x
Last year I started to work on upgrading our project to the 3.0 release. Before that we had already upgraded to Spring Boot 3. Although Spring Cloud AWS 2.4 isn’t compatible with that, it wasn’t much work to make the parts that we used compatible, hence we did use that combination for quite a while.
One difference between the two versions is that with 3.0, the framework now depends on V2 of the AWS Java SDK. Therefore, the first thing I did was to upgrade code that uses the AWS SDK directly to use V2 as well, so that we could get rid of V1 after the migration.
AWS SDK V2
AWS publishes two separate versions of their Java SDK: a V1 and a V2. They have different Maven coordinates and use different packages, so they can be used side-by-side if desired.
V2 has a number of improvements over V1, including a Java 8 baseline and support for streaming and async operations. Migrating from V1 to V2 is not hard, but will require quite some code changes.
Parameter Store Configuration Support
Next up was making the necessary changes for the Parameter Store integration. In the 2.x branch, this support would automatically add multiple property sources for different path prefixes to support shared configuration as well as Spring profile-specific configuration, similar to how e.g. Spring’s Consul integration works.
For the 3.x branch, this automatic support was dropped in favor of simply configuring what you want yourself explicitly: it turned out that although useful, the old functionality was also confusing to many people and less magic would be a Good Thing ™ in this case.
In the old situation, this is how we’d configure the Parameter Store integration in an application.yml file:
# regular config here
# ...
---
spring:
config:
activate:
on-cloud-platform: kubernetes
import: 'aws-parameterstore:'
aws.paramstore.prefix: /config-${ENVIRONMENT}
This means that we’re only activating the support when we’re running on Kubernetes (don’t need this when running locally on your development machine) and we’re overriding the default prefix to include the name of the environment we’re running in, which is given by an environment variable.
With version 3, this has to be rewritten to this:
# regular config here
# ...
---
spring:
config:
activate:
on-cloud-platform: kubernetes
import: 'aws-parameterstore:/config-${ENVIRONMENT}/application/;/config-${ENVIRONMENT}/${spring.application.name}/'
What this shows is that we’re explicitly stating that we want to load both shared configuration stored under /application as well as service-specific configuration stored under the application name. Instead of a common prefix, you simply include the prefix in the paths. We do not use profiles, so we don’t configure anything to load profile-specific configuration as well.
Note that at the time of writing, I believe there’s a little error in the documentation where it doesn’t include the trailing slashes after the path segments.
This is a trivial migration, so after fixing this your code simply works as before.
Note that V3 does add a new option to dynamically pick up configuration changes, by polling the backend (Parameter Store or Secrets Manager), but we do not configure that.
SQS Support
While upgrading the Parameter Store integration was easy, moving to the new SQS support required a bit more work. The reason is that this support has been almost completely rewritten by Tomaz Fernandes. This code is now using the AWS SDK V2 under the hood as well.
Sending messages with a template
The old QueueMessagingTemplate has been replaced with an SqsTemplate, which now implements two interfaces: SqsOperations, for blocking operations similar to what the old template provided, and SqsAsyncOperations as an async version of that. What’s also new is that explicit operations are provided for working with a batch of messages (mapping directly to SQS’s support for that, so this is limited to sizes of max. 10 messages).
Like before, the code supports (un)marshalling of payloads and working with message metadata: a converter using a Jackson ObjectMapper is auto-configured.
In my old code I had extended the QueueMessagingTemplate to provide some async support. The main reason for that was that when you’d send a whole bunch of messages from a single thread, the default sync methods would be extremely slow because the underlying AWS client would wait for other threads to send messages as well using the same template so they can be batched. This causes a delay for each message.
With the new SqsTemplate you get async support out of the box, but another important difference is that the AWS SDK V2 no longer provides this automatic buffering and batching of messages on sending, so you shouldn’t be relying on that anymore and use the batching APIs if you care about sending batches (it will be both faster and cheaper for large volumes).
As a result, most of the changes involved replacing our use of QueueMessagingTemplate with the SqsOperations. For the batch sending use case, we use the SqsAsyncOperations interface instead and call its sendManyAsync method for efficient batching.
Adding message attributes and such can now be done using a callback, like this:
void sendMailWithDelay(Object mailRequest, int delayInSeconds) {
sqsOperations.send(sendOptions -> {
sendOptions.queue(MAIL_REQUESTS_QUEUE)
.delaySeconds(delayInSeconds)
.payload(mailRequest);
});
}
Here we publish a mail request to a queue with a delay.
Note that the request can have different types (any Object is accepted). Another important improvement in V3 is that the new template will automatically add a message attribute containing the fully qualified type name of the message payload, so that on receiving the message it can automatically be unmarshalled to the correct type again. The old QueueMessagingTemplate did not provide this, so before V3 I had to add such an attribute in my own code.
Also note that when sending to a FIFO queue, your code now needs to have the sqs:GetQueueAttributes
IAM permission so it can check if deduplication is enabled, which wasn’t the case before.
Receiving messages with a listener container
Receiving is arguably the most important functionality provided by the framework: without this, you’d need to implement a polling mechanism yourself to know when messages arrive on a queue that you’re interested in. Both the old and the new version of the framework allow you to work with annotated listeners instead, that are called automatically when messages are picked up by the framework’s polling component.
One new feature here is the automatic unmarshalling to the correct type of message payloads in case that the type cannot be inferred from the listener method parameter (assuming you’re using the SqsTemplate to send the messages, of course).
Note, however, that dynamic dispatching to overloaded listener methods based on type is currently not supported like it is in Spring’s JMS or AMQP support. There’s an open issue to address this.
Although a lot has changed in the underlying code, listeners can mostly stay the same when upgrading to V3. One thing that did change is error handling. In V2.4 I used @MessageExceptionHandler
methods in my listener classes to log errors on handling incoming messages. This annotation isn’t supported anymore in V3, but it’s much easier now to perform centralized error handling so that you don’t need to copy this code in every listener class.
I ended up doing this in a non-obvious way, however. The reason for that is that I already had added custom integration of the framework with Micrometer tracing (formerly provided by Spring Cloud Sleuth) and I needed to ensure that when porting this, my error logging would occur within the trace and span belonging to the message that’s received.
Tracing Integration
As mentioned, I already had a solution for tracing in place with the V2.4 version where I subclassed the QueueMessagingTemplate and wrapped the QueueMessageHandler to achieve this. At the time of writing, V3 does not yet provide tracing support out of the box although there is an open issue.
Because we rely very heavily on being able to correlate logging of received messages with the logging of the publishing code I implemented my own point-solution for this. On the sending side this is done by overriding SqsMessagingConverter#fromMessagingMessage where I add a trace header before calling the super’s implementation. Doing it there ensures that we’re on the right thread: as the V3 codebase relies heavily on CompletableFutures it can be tricky to know if you’re still able to access the ThreadLocal-based tracing context sometimes.
For similar reasons I’ve integrated the restoring of the trace context in wrappers for the (Async)MessageListeners that are used to call your listener methods. This is non-trivial to achieve: you can find my setup here.
My actual code also includes error logging, as mentioned above, but I’ll leave it as an exercise to the reader to add that. If you’re interested to learn more, don’t hesitate to contact me personally.
Conclusion
Although upgrading required a lot of code changes, most of those were straightforward.
I could remove my workarounds to make the framework compatible with Boot 3 as well as my custom code to deal with multi-types messages on a single queue.
We’ve been testing the changes for about a week now, and since I haven’t seen any issues we’re taking this to production this week.
I want to thank Maciej, Tomaz and the rest of the new team for all their hard work to get this project into shape again: I for one am a big fan of the framework and I hope that this blog will help others to start using it as well!