Saturday, March 1, 2025

Upgrading to Java 21 and Spring Boot 3: A Comprehensive Guide

The transition from Java 17 to Java 21, paired with an upgrade to Spring Boot 3, is a transformative step for modern application development. This blog post shares the detailed insights, challenges, and solutions from upgrading various services and Lambda functions to these latest versions. We’ll explore the evolution of dependencies, dive into troubleshooting steps, discuss resolutions for common issues, and provide actionable takeaways to help you succeed in your own upgrade journey.

Overview of the Upgrade

The upgrade process entailed moving applications from Java 17 to Java 21 and aligning them with Spring Boot 3. This wasn’t a simple plug-and-play operation—it required careful updates to dependencies, adjustments to configurations, and tweaks to codebases to ensure everything worked harmoniously with the new versions. The process touched multiple components, including core services and AWS Lambda functions, each requiring its own set of changes.

Dependency Evolution

The upgrade unfolded in stages, with dependencies evolving iteratively as issues surfaced and were resolved. Here’s how the key components changed over time:

Java: Initially running on version 17, the leap to Java 21 introduced a "Major version 65" issue, signaling bytecode incompatibility with older tools. This necessitated updates to other dependencies to align with Java 21’s requirements.

Gradle: Starting at version 7.2, we upgraded to 8.1 early in the process to leverage its improved features and compatibility with Java 21. However, this shift triggered initial compile errors, which we addressed as part of broader dependency updates.

Spring Boot: We began with version 2.7.3 but quickly encountered limitations. An intermediate step to 2.7.18 resolved some issues, but JUnit and acceptance test failures persisted. The final move to Spring Boot 3.3.3 was essential to fully support Java 21 and handle the significant shift from javax to jakarta namespaces.

AWS SDK v1: Version 1.12.139 was in use initially, but it proved incompatible with Java 21. We phased it out entirely, relying instead on AWS SDK v2.

AWS SDK v2: Starting at 2.17.162, this remained stable throughout the upgrade, though we later validated its compatibility with the final configuration.

LocalStack: We started with version 1.17.1 for local testing. As issues emerged with Spring Boot 3, we upgraded to 1.20.1 and eventually aligned the LocalStack Docker image to version 3.0.0 for better test reliability.

LocalStack Docker: The initial version, 0.14.0, was outdated for our needs. Upgrading to 3.0.0 ensured compatibility with the updated LocalStack and Spring Boot 3.

Lombok Plugin: Version 6.4.3 caused compile errors with Java 21. Upgrading to 8.10 resolved these issues and ensured smooth integration with the new Java version.

AWS Spring: We began with version 2.4.4, which worked with Spring Boot 2.x. The move to Spring Boot 3 required an update to version 3.1.1 to maintain AWS integration.

Spring Cloud AWS Messaging: Also at 2.4.4 initially, this dependency was ultimately removed as we streamlined our AWS interactions with SDK v2.

Each step in this evolution addressed specific pain points—whether it was compilation failures, test issues, or runtime errors—bringing us closer to a fully functional Java 21 and Spring Boot 3 setup.

Known Issues and Solutions

Throughout the upgrade, several issues emerged that required targeted solutions. Here’s a detailed look at what we encountered and how we resolved them:

1. PortUnreachableException Spamming Logs

Issue: After the Spring Boot upgrade, logs became inundated with errors like:

java.net.PortUnreachableException: recvAddress(..) failed: Connection refused

Cause: This stemmed from a StatsD configuration mismatch introduced by Spring Boot’s updated metrics handling.

Solution: We updated the configuration key from management.metrics.export.statsd.enabled to management.statsd.metrics.export.enabled and explicitly enabled it in the application’s YAML file:

management:

  statsd:

    metrics:

      export:

        enabled: true

This adjustment silenced the log spam and restored proper metrics behavior.

2. Container Privileged Mode

Issue: Running containers in privileged mode clashed with Docker’s user namespaces, causing failures during testing.

Solution: We disabled Testcontainers’ Ryuk resource reaper by setting an environment variable:


TESTCONTAINERS_RYUK_DISABLED=true

This workaround allowed our tests to run smoothly without requiring privileged mode adjustments.

3. Gradle Job Dependency

Issue: A task responsible for running the application failed because it implicitly relied on the output of a jar task without declaring a dependency. The error message highlighted this misconfiguration:


Task uses this output of another task without declaring an explicit or implicit dependency.

Solution: We modified the build.gradle file to explicitly declare the dependency:

afterEvaluate {
    tasks.named('forkedSpringBootRun') {
        dependsOn ':jar'
    }
}

This ensured tasks executed in the correct order, resolving the build failure.

4. Missing AWSCredentials

Issue: After removing AWS SDK v1, we encountered a NoClassDefFoundError: com/amazonaws/auth/AWSCredentials error, indicating a lingering dependency mismatch.

Solution: We updated the LocalStack Docker image to localstack/localstack:3.0.0 and refreshed the Testcontainers dependencies in build.gradle:

testImplementation 'org.testcontainers:localstack'
testImplementation 'org.testcontainers:testcontainers'

This aligned our local testing environment with the updated AWS SDK v2 setup.

5. Acceptance Test Failures

Issue: Acceptance tests failed due to a missing AmazonSQSAsync bean, disrupting validation of AWS SQS interactions.

Solution: We added the spring.cloud.aws.sqs.endpoint property to the configuration and updated the Docker entry point to support Java 21. This restored the bean’s availability and fixed the tests.

public LocalStackContainer create() {
    try (LocalStackContainer localstack =
        new LocalStackContainer(DockerImageName.parse(IMAGE_NAME))
            .withExposedPorts(EXPOSED_PORT)
            .withServices(DYNAMODB, SNS, SQS)
            .withCopyToContainer(forClasspathResource(INIT_LOCALSTACK_SH, 0775),"/etc/localstack/init/ready.d/init-localstack.sh")
            .waitingFor(
                Wait.forLogMessage(LOG_MARKER, 1).withStartupTimeout(Duration.ofMinutes(1)))) {

      return localstack;
    }
  }

Conclusion

Upgrading to Java 21 and Spring Boot 3 is a complex but rewarding endeavor. By navigating challenges like log spam from PortUnreachableException, Gradle task misconfigurations, and AWS SDK transitions, you can modernize your applications for improved performance and maintainability. This guide offers a detailed roadmap to help you avoid common pitfalls and achieve a successful upgrade.