Docker: Multi-node environment with a load balancer from a local Java project war

In this post, we will look at how a developer can quickly create a multi-node environment from his local Java project using Docker Compose.

The post assumes that the reader is familiar with Docker and Docker Compose.

The problem

Suppose you have a local Java application that builds a war file that is deployed to a Tomcat server. When developing locally you can easily run the project from your IDE.
But if our app runs in a highly-available setup in production, we would also like to sometimes test in a similar setup locally.

How can we run the application locally with a load-balancer and multiple instances?

One solution would be to configure multiple Tomcat servers in our IDE and run the app multiple times, then also start a load-balancer locally and point it to our applications. But that requires a lot of configuration and since we know Docker is always the best choice, let us rather use that.

DISCLAIMER: I am joking, you should always weigh the pros and cons and come to a sensible conclusion about which choice is the best in your case.

In my case, Docker and Docker Compose is the better choice because other developers on my team can use the same docker-compose file and have a multi-node setup by running docker-compose up without having to do any extra configuration.

The solution

Docker Compose has options to start multiple instances of a Docker service.

The first option is to use the scale configuration option.
The second option is to use the deploy.replicas configuration option.

And since the scale option was deprecated from the docker-compose file in the spec, we will go for the second option.
To deploy multiple instances of our app we can simply add the option to a docker-compose file like so:

deploy:
    replicas: NUMBER_OF_DESIRED_INSTANCES

After we know how to start multiple instances, we just need to make a docker-compose service for our application. And since our build result is a war file this is quite simple.
We can take our desired server’ docker image and put our war file in the appropriate place in the container.
For example, the docker-compose part for a Tomcat container with our application looks like this:

backend:
	image: tomcat:9.0
	deploy:
        replicas: 3
	expose:
		- "8080"
	volumes:
		- './our-project/target/our-app.war:/usr/local/tomcat/webapps/app.war'

This will create 3 instances of a Tomcat 9 container with our application war deployed under the /app context.

Notice that the port 8080 is under expose and not under ports option to avoid conflicting port binds.

On top of that, we still need a load-balancer to distribute the requests to all three instances. If we use nginx, the full docker-compose file would look like this:

version: '3.8'

services:
  nginx:
	image: nginx:latest
	volumes:
	  - ./nginx.conf:/etc/nginx/nginx.conf:ro
	depends_on:
	  - backend
	ports:
	  - "4000:4000"
  backend:
	image: tomcat:9.0
	deploy:
      replicas: 3
	expose:
	  - "8080"
	volumes:
	  - './our-project/target/our-app.war:/usr/local/tomcat/webapps/app.war'

The final piece is the configuration of the nginx, which is in the nginx.conf file:

user nginx;

events {
	worker_connections   1000;
}

http {
	server {
		listen 4000;
		location / {
			proxy_pass http://backend:8080;
		}
	}
}

The configuration is saying that we want nginx to listen on port 4000 and forward everything to backend services on port 8080. By default, nginx is using a round-robin approach to distribute the requests.

Example project

In our example application we will have a simple Spring Boot webapp that exposes one Hello World endpoint that also contains the application id.

package com.devflection.controllers;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController()
public class HelloController {

	@Value("${app.instance-id}")
	private String applicationUuid;

	@GetMapping("/hello")
	@ResponseStatus(HttpStatus.OK)
	@CrossOrigin(origins = "*")
	public String health() {
		return "Hello from " + applicationUuid;
	}

}

The applicationUuid comes from the application.properties file and is a random UUID.

app.instance-id=${random.uuid}

Next, because we want to deploy our Spring Boot application to a Tomcat container we have to make sure of a couple of things

  • Make sure the main application class extends the SpringBootServletInitializer interface
  • Make sure the packaging is war
  • Make sure the spring-boot-starter-tomcat dependency scope is provided

Afterward, we end up with a war file in the target directory that is ready to be deployed to a Tomcat server. And when we have that, we just adjust the docker-compose file from the above.

version: '3.8'

services:
  nginx:
	image: nginx:latest
	volumes:
	  - ./nginx.conf:/etc/nginx/nginx.conf:ro
	depends_on:
	  - backend
	ports:
	  - "4000:4000"
  backend:
	image: tomcat:9.0
	deploy:
      replicas: 3
	expose:
	  - "8080"
	volumes:
	  - './local-project/target/devflection-local-project.war:/usr/local/tomcat/webapps/app.war'

And we can use the same nginx config file since we are using the same ports and our service has the same name.

Then all we need to do is

docker-compose up

and we end up with 3 instances of our backend, being load-balanced by nginx in a round-robin approach.

If we open http://localhost:4000/app/hello multiple times we should get 3 different UUIDs being repeated:

Hello from bb7bd3eb-45de-4bca-9e1d-a360324fb382

Hello from 99d8e38b-bba8-4054-a765-33a90f7ba626

Hello from f27a8765-4758-4004-b7d5-244f87d4398a

Hello from bb7bd3eb-45de-4bca-9e1d-a360324fb382

Hello from 99d8e38b-bba8-4054-a765-33a90f7ba626

You can browse and get the full example repository on GitHub.

Conclusion

In this post, we looked at how we can use docker-compose to quickly spin up a multi-node environment with a load balancer from our local Java project.
The resulting docker-compose file is quite simple, which makes the whole process to start up a multi-node environment very easy.


This is it for a multi-node environment setup using Docker Compose and a local Java project.
Thank you for reading through, I hope you found this article useful.