Journeys in Java, Level 9: Docker compose all the things
- 14 minutes read - 2822 wordsOur microservices project contains quite a few pieces now. We have two databases, three API services, a user-view service for books, and a service to host our configuration. With so many pieces to manage, it would be nice to have something that orchestrates the individual services into a system, such as Docker Compose.
Back in our Level 5 rendition, we did exactly this for our smaller version of the project. Now that we have expanded our services, we need to add those new pieces into the existing Docker Compose management umbrella.
I expected this step of the process to be much quicker, especially since I had done it before. However, there were several obstacles I encountered that took much longer to solve than anticipated. I’ll do my best to cover those thoroughly here and avoid future problems for both myself and others!
Architecture
We built this project from scratch with a rudimentary scope and functionality and have slowly expanded the complexity. In the most recent step, we had a few services managed by Docker Compose and a few managed manually. This is because we wanted to avoid adding unnecessary complexity with an orchestration layer until we were certain service communication was working without it.
Now that things are operating well independently, we can migrate the working services so that everything is handled by Docker Compose.
Updated architecture:
There is a small grey border around each service indicating a containerized application. Services are tied to other services through arrows with some boxes to show the type of objects passed back and forth. The larger grey box encompassing everything represents how Docker Compose is orchestrating all of services as a single unit.
Prepping applications
The first step was to integrate the config-service to Docker Compose. This service handles database credentials to both MongoDB and Neo4j. I expected this step to be the trickiest, but in reality, getting service1
to interact with the config service proved to be the biggest hurdle, and the one I spent the most time on. Once that was operating smoothly, adding the rest of the services took very little time.
Let’s walk through the steps!
Goodreads-config
First, we need to containerize the application using a Dockerfile. After copying/pasting from another service, I found out that my base openjdk
image was deprecated. There were a few suggestions for alternatives, but went with Azul’s image as the new base image.
Next, we need to package the application from the service’s directory using mvn clean package
and build the Docker image with the containerized application with docker build -t jmreif/goodreads-config .
at the command line. Our application is packaged and the Docker image built, so we need to add the service to our docker-compose.yml
file.
version: "3.9"
services:
#goodreads-db...
goodreads-config:
container_name: goodreads-config
image: jmreif/goodreads-config
# build: ./config-server
ports:
- "8888:8888"
depends_on:
- goodreads-db
environment:
- SPRING_PROFILES_ACTIVE=native,docker
volumes:
- $HOME/Projects/config/microservices-java-config:/config
- $HOME/Projects/docker/goodreads/config-server/logs:/logs
networks:
- goodreads
networks:
goodreads:
I temporarily commented out the previous service1, service2, and service3 blocks, so that I could focus on adding each piece individually. The goodreads-config
service contains many of the same fields we have used before. I added a commented-out build
option test and make changes locally, rather than pulling from the remote image as the parameter above it does. The depends_on
option specifies that the database container must be started before this service can start. Note: This does not mean that the database container is in a "ready" state, only started.
Under the environment
block, we have a variable set up for a Spring profile. This allows us to use different credentials, depending on whether we are running in a local test environment or in Docker. The main difference is the use of localhost
to connect to other services in a local environment (specified by native
profile), versus container names in a Docker environment (profile: docker
). The next option for volumes
sets up the location of the config files (for now, in a local config
directory) and log files.
*Note:* I played around with moving the profile variable into the config file, but found out that we need it as a variable in the container (either from the application in application.properties
or in docker-compose.yml). Sourcing it from outside the container didn’t cooperate.
Let’s test what we changed so far!
Round 1: Put it to the test!
We can run this much of the system with a single command.
docker-compose up -d
*Note:* If you are building local images with the build field in docker-compose.yml, then use the command docker-compose up -d --build
. This will build the Docker containers each time on startup from the directories.
Next, we can test our goodreads-config
service by accessing the configuration file it is hosting. We can do this at the command line with curl localhost:8888/mongo-client/docker
or using a browser with the URL localhost:8888/mongo-client/docker
. This should show something like the screenshot below.
Bring everything back down again with another command.
docker-compose down
Goodreads-svc1: Interact with goodreads-config
As mentioned above, this was the toughest part to get working, but I learned a few things along the way.
We already had Docker Compose managing this service in the previous Level 5 version of the code, so we can use that setup with some adjustments.
version: "3.9"
services:
#goodreads-db...
#goodreads-config...
goodreads-svc1:
container_name: goodreads-svc1
image: jmreif/goodreads-svc1:lvl9
# build: ./service1
ports:
- "8081:8081"
depends_on:
- goodreads-config
restart: on-failure
environment:
- SPRING_APPLICATION_NAME=mongo-client
- SPRING_CONFIG_IMPORT=configserver:http://goodreads-config:8888
- SPRING_PROFILES_ACTIVE=docker
networks:
- goodreads
The first several options are the same as our previous services, although I added a tag to the image name to keep a separate image with these updates. The next change is on the depends_on
option. Instead of waiting directly for the database container, service1
actually depends on the config service (goodreads-config
) for the database credentials. The config service then forwards the call with appropriate credentials.
On the next line is a new option - restart
. This took the longest time for me to debug, but you might remember me mentioning that depends_on
only waits for the container to start, not for the service to be ready. It turns out that service1
was starting too early, so it would fail to find the configuration. After trying a few different methods, such as building in request retries in the application itself, I discovered that the only working solution was to restart the whole container (or at least the entire application within the container). The most straightforward way to do this was through the restart
option in Docker Compose. This solved the startup and configuration issues I was seeing.
Lastly, the environment variables specify the application name, location of the config server, and Spring profile. The application name and active profile help the application find the appropriate configuration file on the config server. The SPRING_CONFIG_IMPORT
variable tells the container where to look for the config server. I also noticed that these properties did not work correctly if I put them in the config file itself. The values must be accessible within the container, or it would not know where to look.
Service1: Application Changes
As I was debugging the restart issues mentioned above, one suggestion to add resiliency to the application and assist with determining errors was to add Spring Retry capabilities. This allows us to set up guidelines for automatically retrying requests, which is especially helpful when safeguarding against situations like network interruptions. While it didn’t solve the Docker Compose container startup issues, I kept the code to make the application more robust and assist debugging.
There wasn’t much code to add, and I followed a colleague’s advice, alongside Baeldung’s article.
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
First, we need the retry dependency, alongside the Spring Boot starter for Aspect-Oriented Programming. I also added Actuator, which will set up endpoints to inspect application health, metrics, and more.
Next, we need to comment out the local properties in the src/main/resources/application.properties
file so that the config server variables don’t conflict with ones we are setting in the container (via docker-compose.yml
). Otherwise, it would connect to the container config server, but also try to connect to a local config server. It would continue to retry until it failed, causing the application to crash searching for irrelevant, backup property values. Lesson learned: the retry applied to any properties set, whether in the application or environment variables.
Finally, I need to add a few annotations to the application class.
@SpringBootApplication
@EnableRetry
public class Service1Application {
....
}
@RestController
@RequestMapping("/db")
@AllArgsConstructor
class BookController {
....
@Retryable
@GetMapping("/books")
Flux<Book> getBooks() { return bookRepository.findAll(); }
@Retryable
@GetMapping("/book/{mongoId}")
Mono<Book> getBook(@PathVariable String mongoId) { return bookRepository.findById(mongoId); }
}
The @EnableRetry
annotation enables retry functionality in the application. The @Retryable
annotation on the two methods tells the application which methods should utilize retry logic. Default configuration for retries is set to try the request up to three times with a delay of one second between each retry. However, we can customize the defaults through properties, annotation parameters for each method, or template configuration.
Notice that I did not add retry logic to the liveCheck()
method. This is because we don’t interact with other services or have other dependencies that might potentially cause flakiness in the requests. If the liveCheck()
method does not work, then our application or container is not running.
While I was here, we can upgrade the Spring Boot project to use the latest versions of everything, so I modified the Spring Boot version, Java version, and Spring Cloud version in the pom.xml
. I also updated the Dockerfile’s base image to the Azul JDK 17, as well.
With those changes in place, we will need to re-package the application with mvn clean package -DskipTests=true
and rebuild the local Docker container. I also made some tweaks to the config file.
*Note:* the -DskipTests=true
is necessary because it will look for a config server and fail when/if it doesn’t find it.
Config file: mongo-client
In the mongo-client configuration file hosted by the config server, I added a couple more properties for opening up Actuator endpoints (for application debugging) and the application name
# Enable all actuator endpoints FOR DEMO PURPOSES ONLY!
management:
endpoints:
web:
exposure:
include: "*"
spring:
application:
name: mongo-client
....
Round 2: Put it to the test!
Let’s run all of the pieces so far - goodreads-db, goodreads-config, goodreads-svc1.
docker-compose up -d
*Note:* If you are building local images with the build field in docker-compose.yml, then use the command docker-compose up -d --build
. This will build the Docker containers each time on startup from the directories.
Next, we can test our services with a few endpoints.
Goodreads-config: command line with
curl localhost:8888/mongo-client/docker
.Goodreads-svc1: command line with
curl localhost:8081/db
,curl localhost:8081/db/books
, andcurl localhost:8081/db/book/623a1d969ff4341c13cbcc6b
or web browser with only URL.
When we are done testing this, we can bring down the system with docker-compose down
.
Goodreads-svc2: Interact with service1
This service is our user-facing service for interacting with book data. All of the changes made to this service will look familiar because we made the same changes in service1
!
version: "3.9"
services:
#goodreads-db...
#goodreads-config...
#goodreads-svc1...
goodreads-svc2:
container_name: goodreads-svc2
image: jmreif/goodreads-svc2:lvl9
# build: ./service2
ports:
- "8080:8080"
depends_on:
- goodreads-svc1
restart: on-failure
environment:
- BACKEND_HOSTNAME=goodreads-svc1
networks:
- goodreads
networks:
- goodreads
We only add the restart: on-failure
option here. Because we could have network interruptions that cause services to miss requests or other flaky behavior, I wanted to build the same resiliency into my other applications and containers that I did with service1
. For a refresher on the BACKEND_HOSTNAME
environment variable, check out the Level 5 blog post.
Service1: Application Changes
First, we want to add the Spring Retry logic to service2
by adding the three dependencies in the pom.xml
(lines 29-40). Next, we can add the property to the application.properties
to open all Actuator endpoints. Note: We only do this in development or local testing - not production! We can comment out that property here for now. Finally, we can add @EnableRetry
to the main application class and @Retryable
to each method we want to use retry logic. In our case, only the getBooks()
method.
We have already built in flexibility to service2
for local or remote testing with the hostname
property. More detail on how and why we did that is in the Level 5 blog post.
While we are here, we can update the Spring Boot project to use the latest versions of everything for Spring Boot and Java. I also updated the service’s Dockerfile to use the Azul JDK 17 as the base image.
We need to re-package the application and build the local container, just as we did in service1
. Because this service interacts only with service1
, it doesn’t need any ties to the config service or database. Let’s test it!
Round 3: Put it to the test!
We’ll use the docker-compose up -d
command, as we did before, to spin up Docker Compose.
Then we test our endpoints.
Goodreads-config: command line with
curl localhost:8888/mongo-client/docker
.Goodreads-svc1: command line with
curl localhost:8081/db
,curl localhost:8081/db/books
, andcurl localhost:8081/db/book/623a1d969ff4341c13cbcc6b
.Goodreads-svc2: command line with
curl localhost:8080/goodreads
andcurl localhost:8080/goodreads/books
or web browser with the URL.
And docker-compose down
will shut down everything gracefully.
Goodreads-svc3: Backend service for Authors
Our third service is a near copy of service1
, but it hosts author data (rather than books). Pretty much everything we learned before is also applied to this service3
. We will list the changes for review.
Docker Compose: add configuration for
service3
(values matchservice1
).Application
pom.xml
: add three depedencies for retry and monitoring (lines 38-49). Also upgrade versions for Spring Boot, Java, and Spring Cloud.Application
application.properties
: add Actuator endpoints property and comment out several (for local testing only).Application
Service3Application
: add@EnableRetry
to main application class and add@Retryable
to desired methods (getAuthors()
andgetAuthor(id)
).Dockerfile: use Azul JDK 17 as base image.
Application: re-package app with
mvn clean package -DskipTests=true
and build local Docker containerConfig file: no changes because it will use the same values that
service1
uses.
Let’s test!
Round 4: Put it to the test!
As usual, use docker-compose up -d
at the command line to spin up our microservices system and test the endpoints with the commands listed below.
Goodreads-config: command line with
curl localhost:8888/mongo-client/docker
.Goodreads-svc1: command line with
curl localhost:8081/db
,curl localhost:8081/db/books
, andcurl localhost:8081/db/book/623a1d969ff4341c13cbcc6b
.Goodreads-svc2: command line with
curl localhost:8080/goodreads
andcurl localhost:8080/goodreads/books
.Goodreads-svc3:
curl localhost:8082/db
,curl localhost:8082/db/authors
, andcurl localhost:8082/db/author/623a48c1b6575ea3e899b164
or web browser with only URL.
Close the system with docker-compose down
.
Goodreads-svc4: Backend service for Reviews
Similar to services one and three, service4
is a backing service for book review data. However, this service interacts with a graph database in the cloud (Neo4j). More background on this service is in the Level 6 blog post. Let’s list out our changes to bring service4
into Docker Compose.
Docker Compose: add configuration for
service4
.Application
pom.xml
: add three depedencies for retry and monitoring (lines 38-49). Upgrade versions for Spring Boot, Java, and Spring Cloud.Application
application.properties
: add Actuator endpoints property and comment out several (for local testing only).Application
Service4Application
: add@EnableRetry
to main application class and add@Retryable
to desired methods (getReviews()
andgetBookReviews(id)
).Dockerfile: use Azul JDK 17 as base image.
Application: re-package app with
mvn clean package -DskipTests=true
and build local Docker containerConfig file
neo4j-client
: add Actuator endpoint property and application name.
We now have all of our services migrated to Docker Compose. Time to test the entire system!
Round 5 (final!): Put it to the test!
We can run our system with the same command we have been using.
docker-compose up -d
Next, we can test all of our endpoints.
Goodreads-config (mongo): command line with
curl localhost:8888/mongo-client/docker
.Goodreads-svc1: command line with
curl localhost:8081/db
,curl localhost:8081/db/books
, andcurl localhost:8081/db/book/623a1d969ff4341c13cbcc6b
.Goodreads-svc2: command line with
curl localhost:8080/goodreads
andcurl localhost:8080/goodreads/books
.Goodreads-svc3:
curl localhost:8082/db
,curl localhost:8082/db/authors
, andcurl localhost:8082/db/author/623a48c1b6575ea3e899b164
.Goodreads-config (neo4j): command line with
curl localhost:8888/neo4j-client/docker
.Neo4j database: ensure AuraDB instance is running (free instances are automatically paused after 3 days).
Goodreads-svc4:
curl localhost:8083/neo
,curl localhost:8083/neo/reviews
, andcurl localhost:8083/neo/reviews/178186
or web browser with only URL.
Bring everything back down again with the below command.
docker-compose down
Wrapping up!
We have successfully created an orchestrated microservices system with Docker Compose!
We saw how Docker Compose can throw some tricky environment issues at us related to startup order and dependencies between microservices, but we were able to navigate those with additional configuration options. We also built resiliency into our application services with Spring Retry to help us handle service or network interruptions.
The microservices system now includes a database container (MongoDB), configuration service, three backing services (service1
, service3
, and service4
), and a user-facing service (service2
). We also connect to an external, cloud-hosted Neo4j database.
We have grown this system, and we hope to continue adding functionality and learning along the way. Happy coding!
Resources
Github: microservices-level9 repository
Blog post: Simple Fix If Your Dockerized App Crashes…
Documentation: Docker Compose - restart
Neo4j AuraDB: Create a FREE database