Setting Up a Local Redis Cluster Using Testcontainers in Spring Boot

Truong Bui
10 min readJun 1, 2023

--

Part of the Testcontainers Article Series: If you haven’t read my other articles yet, please refer to the following links:
1. Setting Up a Local MariaDB Using Testcontainers in Spring Boot
2. Setting Up a Local Kafka Using Testcontainers in Spring Boot
3. Setting Up a Local Jira Instance Using Testcontainers in Spring Boot

After observing my series of articles about Testcontainers, you might think “Wow, this guy is quite obsessed with Testcontainers!” 😆. Well, if that’s the case, I can say it’s situation-dependent, but It’s amazing, isn’t it? 😃. Having utilized Testcontainers myself, I felt compelled to write and share my experiences with it. 😃

Let’s return to our topic today.

In a scenario where your application utilizes Redis to store the results of expensive operations, the performance can be significantly improved. Redis is essential in all three primary environments: Development (Dev), Staging (Stg), and Production (Prod). While Staging (Stg) and Production (Prod) environments have dedicated Redis instances, establishing a connection is straightforward. However, in the Development (Dev) environment, having a Redis Cluster is optional, posing a challenge for connecting to Redis in this setting.

Now, the question arises: What can be done when a Redis connection is needed in the Development (Dev) environment without an existing Redis instance? In this circumstance, there are two common solutions we can consider.

  1. Pre-configuring Redis by either installing it on your personal machine or utilizing a Docker image. However, this setup poses certain challenges. For instance, setting up a Redis Cluster on a personal machine can be difficult and time-consuming, particularly for newcomers to the project. Furthermore, finding suitable Redis Cluster Docker images is not always straightforward. Even if you manage to find one, running the Docker image assigns a random port, requiring you to update the local YAML configuration every time.
  2. Alternatively, you would need to connect to the Staging (Stg) Redis, which might be inconvenient in certain scenarios. This setup can cause issues, such as unintended impacts on the Quality Assurance (QA) team testing the app on Stg or something that may be affected due to local operations.

In a previous similar situation, I successfully resolved this issue by utilizing Testcontainers. With the help of Testcontainers, we can overcome the mentioned challenges. Today, I will demonstrate the setup of a local Redis Cluster using Testcontainers in a small project. Setting up a local Redis Cluster may be more complex compared to MariaDB or Kafka, as discussed in my previous articles 😆. However, I will provide comprehensive explanations at each step and include official documentation links for reference 😎.

Now let’s get started!!! 💪

Prerequisites

  • Java 17
  • Maven Wrapper
  • Spring Boot 3+
  • Swagger (for testing purposes)
  • Docker runtime in advance (Docker Install)

Defining Dependencies

Create a new Spring Boot project named “redis-testcontainers” with the dependencies listed in the provided POM file.

https://github.com/buingoctruong/springboot-testcontainers-redis-cluster/blob/master/pom.xml

Defining Applications Profiles

In Spring Boot, profiles are a way to configure and customize the application based on different environments. For instance, consider our sample application that operates in 2 environments: Dev and Stg. In this case, the application can support the following profiles:

  • application.yaml: Settings common for all profiles.
  • application-local.yaml: Settings specific to local profile.
  • application-stg.yaml: Settings specific to stg profile.

The app will load all settings from application.yaml and then will load all settings from application-<profile>.yaml, overwriting previous values. By default local profile is used.

The settings for all profiles can be found here: https://github.com/buingoctruong/springboot-testcontainers-redis-cluster/tree/master/src/main/resources

Configuring Redis Cluster Properties

We specifically focus on a subset of properties for our Redis Cluster, defining their corresponding values in the application.yaml file first.

redis:
config:
read-timeout: 1s
max-idle-pool: 8
max-total-pool: 8
max-wait: 1s
min-idle-pool: 0
nodes:
- localhost: 7000
- localhost: 7001
- localhost: 7002
- localhost: 7003
- localhost: 7004
- localhost: 7005

Using the @ConfigurationProperties annotation, we can easily bind properties from the application.yaml file to member variables of a specific class. In this case, all properties in the application.yaml file with the prefix “redis.config” will be automatically bound to the corresponding member variables of the “RedisProperties” class. (Detailed explanations on member variables are included as comments)

@Data
@ConfigurationProperties(prefix = "redis.config")
public class RedisProperties {
// obtained from redis.config.read-timeout
private final Duration readTimeout;
// obtained from redis.config.nodes
private final List<String> nodes;
// obtained from redis.config.max-total-pool
private final int maxTotalPool;
// obtained from redis.config.max-idle-pool
private final int maxIdlePool;
// obtained from redis.config.min-idle-pool
private final int minIdlePool;
// obtained from redis.config.max-wait
private final Duration maxWait;
...
}

After obtaining values for the member variables, we can proceed to construct the “GenericObjectPoolConfig” object. This object is responsible for efficiently managing and controlling the pooling behavior of Redis connections. Fill in the “” section in the above class with the following snippet.

public <T> GenericObjectPoolConfig<T> getPoolConfig() {
GenericObjectPoolConfig<T> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(maxTotalPool);
config.setMaxIdle(maxIdlePool);
config.setMinIdle(minIdlePool);
config.setMaxWait(maxWait);
return config;
}

For further details on GenericObjectPoolConfig, you can refer to this link: GenericObjectPoolConfig

Configuring Redis Client

Spring Data Redis supports 2 drivers out of the box: Jedis and Lettuce.

I chose Lettuce for its superior performance and enhanced control, although it does require a little bit more setup. However, the choice depends on your personal preferences and requirements.

First, let’s create a ClientResources Bean, which is required when configuring Lettuce, by utilizing this Bean we can provide resources and configurations for Redis client. In our sample project, we’re using default settings.

I annotated this Bean with @Profile("!local"). This annotation ensures that the bean is created only in non-local environments. Further details about this idea will be explained in the “Local Redis Cluster Setup” section.

@Profile("!local")
@Bean(destroyMethod = "shutdown")
public ClientResources redisClientResources() {
return DefaultClientResources.create();
}

To have control over the behavior of Redis Client, we need a ClientOptions Bean. In our project, we have enabled the capability to reject requests in a disconnected state and enable automatic reconnection.

@Bean
public ClientOptions redisClientOptions() {
return ClientOptions.builder()
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.autoReconnect(true).build();
}

Next, we need a LettucePoolingClientConfiguration Bean to configure the pooling behavior of Redis connections in Lettuce. This bean enables us to customize and optimize the way Lettuce manages and reuses Redis connections.

@Bean
public LettucePoolingClientConfiguration lettucePoolingClientConfiguration(
ClientOptions redisClientOptions, ClientResources redisClientResources,
RedisProperties redisProperties) {
return LettucePoolingClientConfiguration.builder()
.commandTimeout(redisProperties.getReadTimeout())
.poolConfig(redisProperties.getPoolConfig()).clientOptions(redisClientOptions)
.clientResources(redisClientResources).build();
}

We have Redis Cluster, to configure the Redis Cluster in Lettuce, we utilize a Bean name RedisClusterConfiguration, this Bean provides the necessary settings to connect and interact with a Redis Cluster.

@Bean
public RedisClusterConfiguration customRedisCluster(RedisProperties redisProperties) {
return new RedisClusterConfiguration(redisProperties.getNodes());
}

To establish connections to the Redis server in a Spring application, we use RedisConnectionFactory, this factory is responsible for creating and managing Redis connections.

@Bean
public RedisConnectionFactory lettuceConnectionFactory(
RedisClusterConfiguration customRedisCluster,
LettucePoolingClientConfiguration lettucePoolingClientConfiguration) {
LettuceConnectionFactory factory = new LettuceConnectionFactory(customRedisCluster,
lettucePoolingClientConfiguration);
factory.afterPropertiesSet();
return factory;
}

Lastly, we require a RedisTemplate bean, which facilitates the serialization and deserialization between Redis and the Spring application.

@Bean
public <T> RedisTemplate<String, T> redisTemplate(ObjectMapper objectMapper,
RedisConnectionFactory lettuceConnectionFactory) {
RedisTemplate<String, T> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(lettuceConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setDefaultSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));
redisTemplate.afterPropertiesSet();
return redisTemplate;
}

That’s all for the configuration of the Lettuce client. All Beans are wrapped within a configuration class named “RedisClientConfig”.

You can refer to the guide available here for detailed instructions: Configuring the Lettuce Connector

Controller

Reaching this section means that we have completed the Redis client configurations tasks. Now, let’s proceed to create a basic controller for testing Redis functionality on the local machine.

@RestController
@RequestMapping("/redis")
@RequiredArgsConstructor
public class RedisController {
private final RedisTemplate<String, String> redisTemplate;
@GetMapping(path = "/object/{key}", produces = MediaType.TEXT_PLAIN_VALUE)
public String getObject(@PathVariable("key") String key) {
return redisTemplate.opsForValue().get(key);
}

@PostMapping(path = "/object")
public void pushObject(@RequestBody RedisRequest redisRequest) {
redisTemplate.opsForValue().set(redisRequest.getKey(), redisRequest.getValue(),
Duration.ofMinutes(1));
}

@DeleteMapping(path = "/object/{key}")
public boolean deleteObject(@PathVariable("key") String key) {
return redisTemplate.delete(key);
}
@Data
public static class RedisRequest {
private final String key;
private final String value;
}
}

Local Redis Cluster Setup

Alright, let’s proceed to the most interesting section 😆

As you may have noticed, the value of “redis.config.nodes” in the application.yaml file is merely a placeholder. There is no actual Redis Cluster exists with the host/port pair list connection “localhost: 7000, 7001, 7002, 7003, 7004, 7005”.

Following the same steps as in my previous articles with MariaDB and Kafka, including utilizing Testcontainers to set up a Redis container running the container retrieving the host/port pair list updating the “redis.config.nodes” configuration It will not work!

That’s why I said at the beginning of this article “Setting up a local Redis Cluster may be more complex compared to MariaDB or Kafka” 😆

There is an additional step - step5 that we need to perform during application startup, let’s take a closer look at it.

  1. Utilize Testcontainers for constructing a container implementation for Redis Cluster. (grokzen/redis-cluster:6.0.7” is the only suitable Redis Cluster Docker image I could find 😂 )
  2. Starts the container using docker, pulling an image if necessary.
  3. Retrieve the host/port pair list connection of the recently started container.
  4. Update the value of “redis.config.nodes” in the application.yaml file.
  5. Create an additional ClientResources Bean to force Lettuce to read the updated value of “redis.config.nodes” in the application.yaml file. This Bean is specifically for the local environment. This allows us to separate it from the normal one defined in the “RedisClientConfig” class, which is utilized in other environments. (Now the idea behind using @Profile("!local") on ClientResources Bean in the “RedisClientConfig” class should now be clear)

We continue to leverage the ApplicationContextInitializer interface to execute code during application startup. For those unfamiliar with the ApplicationContextInitializer interface, it accepts ApplicationContextInitializedEvent, which is dispatched when ApplicationContext becomes available, but before any bean definitions are loaded.

@Configuration
public class LocalRedisInitializer
implements
ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final Set<Integer> redisClusterPorts = Set.of(7000, 7001, 7002, 7003, 7004,
7005);
private static final List<String> nodes = new ArrayList<>();
private static final ConcurrentMap<Integer, Integer> redisClusterNotPortMapping = new ConcurrentHashMap<>();
private static final ConcurrentMap<Integer, SocketAddress> redisClusterSocketAddresses = new ConcurrentHashMap<>();
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
redisLocalSetup(applicationContext);
}

private void redisLocalSetup(ConfigurableApplicationContext context) {
ConfigurableEnvironment environment = context.getEnvironment();
GenericContainer<?> redis = new GenericContainer<>(
DockerImageName.parse("grokzen/redis-cluster:6.0.7"))
.withExposedPorts(redisClusterPorts.toArray(new Integer[0]));
redis.start();
String hostAddress = redis.getHost();
redisClusterPorts.forEach(port -> {
Integer mappedPort = redis.getMappedPort(port);
redisClusterNotPortMapping.put(port, mappedPort);
nodes.add(hostAddress + ":" + mappedPort);
});
setProperties(environment, "redis.config.nodes", nodes);
}

@Bean(destroyMethod = "shutdown")
public ClientResources redisClientResources() {
final SocketAddressResolver socketAddressResolver = new SocketAddressResolver() {
@Override
public SocketAddress resolve(RedisURI redisURI) {
Integer mappedPort = redisClusterNotPortMapping.get(redisURI.getPort());
if (mappedPort != null) {
SocketAddress socketAddress = redisClusterSocketAddresses.get(mappedPort);
if (socketAddress != null) {
return socketAddress;
}
redisURI.setPort(mappedPort);
}
redisURI.setHost(DockerClientFactory.instance().dockerHostIpAddress());
SocketAddress socketAddress = super.resolve(redisURI);
redisClusterSocketAddresses.putIfAbsent(redisURI.getPort(), socketAddress);
return socketAddress;
}
};
return ClientResources.builder().socketAddressResolver(socketAddressResolver).build();
}

private void setProperties(ConfigurableEnvironment environment, String name, Object value) {
MutablePropertySources sources = environment.getPropertySources();
PropertySource<?> source = sources.get(name);
if (source == null) {
source = new MapPropertySource(name, new HashMap<>());
sources.addFirst(source);
}
((Map<String, Object>) source.getSource()).put(name, value);
}
}

I will assign the exploration of constructing container implementations using Testcontainers and the ApplicationContextInitializer interface as homework for you.

Testcontainers should only be utilized in the local environment. This configuration class is created to facilitate local application execution with a Redis Cluster. Therefore, it should be moved to the test folder.

Local Application Startup Class

To start up the Spring ApplicationContext, we need a Spring Boot application’s main class that contains a public static void main() method.

Inside the test folder, there exists a class named “RedisTestcontainersApplicationTests”. I have renamed it to “RedisAppRunner” 😆 and made the following updates.

@SpringBootTest
@ComponentScan(basePackages = "io.github.truongbn.redistestcontainers")
@ConfigurationPropertiesScan(basePackages = "io.github.truongbn.redistestcontainers")
class RedisAppRunner {
public static void main(String[] args) {
new SpringApplicationBuilder(RedisAppRunner.class).initializers(new LocalRedisInitializer())
.run(args);
}
}

Time to play with Testcontainers

Now, everything is ready! 😎

To launch the application, run RedisAppRunner.main() method, it should run successfully on port 8080.

The initial run may take some time as Testcontainers needs to set up all the necessary components for the docker instance. 😅 However, this setup process only occurs once. The positive side is that from now on, we can launch the application locally at any time without the need for manual configuration of Redis-related tasks.

If you encounter this issue during your initial run, and you find yourself in a similar situation as I did, you can refer to this link for more details: https://github.com/testcontainers/testcontainers-java/discussions/6045

  • Try out POST:/redis/object”, the message should be pushed into Redis.
  • Try out GET:/redis/object/{key}”, the expected result should be as follow.
  • Try out DELETE: “/redis/object/{key}”, the expected result should be as follow.
  • Try out GET:/redis/object/{key}” again, message shouldn’t be returned since It was deleted.

The article became lengthy LOL 😂 That’s due to the detailed explanations.

We have just completed a brief demonstration to observe the setup of a local Redis Cluster using Testcontainers. Additionally, Testcontainers can be utilized for writing integration tests. Isn’t amazing? 😃 hope it’s working as expected you guys!

Completed source code can be found in this GitHub repository: https://github.com/buingoctruong/springboot-testcontainers-redis-cluster

I would love to hear your thoughts!

Thank you for reading, and goodbye!

--

--