Manage Microservices With Docker Compose

As microservices systems expand beyond a handful of services, we often need some way to coordinate everything and ensure consistent communication (avoid human error). Tools such as Kubernetes or Docker Compose have quickly become commonplace for these types of workloads. Today’s example will use Docker Compose.

Docker Compose is an orchestration tool that manages containerized applications, and while I have heard many lament the complexity of Kubernetes, I found Docker Compose to have some complexities as well. We will work through these along the way and explain how I solved them.

For our project, we have two high-level steps to get managed microservice containers - 1. containerize any currently-local applications, 2. set up the management layer with Docker Compose.

Let’s get started!

Architecture

Our microservices system contains quite a few pieces to manage. We have two databases, three API services, a user-view service, and a configuration service. With so many components needing coordination, Docker Compose will reduce manual interaction for starting, stopping, and maintaining individual services by orchestrating them as a united group.

The border around each service represents the container housing each application. Notice that only one service is not containerized - Neo4j. The database is managed separately in Neo4j Aura (a Neo4j-hosted cloud service), so we don’t containerize and manage it ourselves. The rest of the services are encompassed in a gray box denoting what Docker Compose handles.

Application Setup

There are a few steps I would recommend for integrating a microservice with an orchestration tool like Docker Compose. Mostly, they focus on testing and resiliency concerns. Networking, communication, and configuration become a bit more complicated in a microservices environment, so I took a few extra steps and precautions to simplify those components.

  1. Create an internal test method. When requests don’t respond, there are many different variables, making it hard to debug. By including a “ping”-like method, we can test only whether the application is live and reachable. Example code is in the GitHub repository for each service.

  2. Build in resiliency with retry logic and open health check access. If requests fail or take too long, it is good practice to build in some plan for how you want the service and/or method to react. While doing this didn’t solve all the problems, having the app handle intermittent interruptions on its own takes out some of the guesswork later. By exposing health endpoints, we can also inspect various aspects of the app. More about this is outlined in the application changes section of this blog post.

Containerizing Services

In order for Docker Compose to manage services, we need to put them into containers. We can use a Dockerfile to set up the commands needed in order to build a container (like a mini virtual machine) that includes the application and any other components we need available.

There are a lot of options for what to put in a container, but we will stick with just a few necessary requirements.

Dockerfile
 
#Pull base image
#-----------------
FROM azul/zulu-openjdk:17

#Author
#-------
LABEL org.opencontainers.image.authors="Jennifer Reif,jennifer@thehecklers.org,@JMHReif"

#Copy jar and expose entrypoints
#--------------------------------
COPY target/service1-*.jar goodreads-svc1.jar
ENTRYPOINT ["java","-jar","/goodreads-svc1.jar"]


First, we need a Java environment in the container (since we have Java-based applications), so the FROM command will pull Azul’s version 17 image as the base layer. Next, the LABEL command tells users who owns/maintains the file. Lastly, there are a couple of instructions to copy the JAR file (packaged application) into the container (COPY) and then add commands/arguments for the build command (ENTRYPOINT).

We will need a similar file in each service folder (4 services + config service + mongodb service = 6 total). Next, we need to package each application by going into each folder and running the below command to create the bundled application JAR file.

Shell
 
mvn clean package


Note: For service1, service3, and service4, we need to add -DskipTests=true to the end of the above command. Those services rely on a config server application, and if the tests don’t find one running, the build will fail. The extra option tells Maven to skip that part.

Now that we have the application JAR files ready, we need to create a docker-compose.yml file that outlines all the commands and configuration for setup to run the services as a unit!

Docker-compose.yml

A YAML file will include container details, configuration, and commands that we want Compose to execute. It will list each of our services, then specify subfields for each. We will also set up a dedicated network for all of the containers to run on. Let’s build the file piece by piece in the main project folder using the template below.

YAML
 
version: "3.9"
services:
  <service>:
    <field>: <value>
networks:
  goodreads:


The first field displays the Docker compose version (not required). Next, we will list our services. The child fields for each service can hold details and configurations. We will go through those in the next subsections. Lastly, we have a networks field, which specifies a custom network that we want the containers to join. This was the part that took some time to figure out. Docker compose documentation has a page dedicated to networking, but I found the critical information easy to miss.

To summarize, if we do not specify a custom network in the Docker compose file, it will create a default network. Each container will only be able to communicate with other containers on that network via IP address. This means if containerA wants to talk to containerB, a call would look like curl http://127.0.0.2:8080. However, if IP addresses expire or rotate, then any references would need to dynamically retrieve the container’s IP address before calling.

One way around this is to create a custom network, which allows containers to reference one another by container name, instead of just IP address. This an improvement, both to solve dynamic IP issues, as well as human memory/reference issues. Therefore, the networks field in the docker-compose.yml states any custom network names along with any configurations. Since we don’t need anything fancy, the network name goodreads is the only thing here. We will then assign the services to that same network with an option under each service.

Now we need to fill in the services and related options under the services section.

MongoDB Database

YAML
 
version: "3.9"
services:
  goodreads-db:
    container_name: goodreads-db
    image: jmreif/mongodb
    environment:
      - MONGO_INITDB_ROOT_USERNAME=mongoadmin
      - MONGO_INITDB_ROOT_PASSWORD=Testing123
    ports:
      - "27017:27017"
    volumes:
      - $HOME/Projects/docker/mongoBooks/data:/data/db
      - $HOME/Projects/docker/mongoBooks/logs:/logs
      - $HOME/Projects/docker/mongoBooks/tmp:/tmp
    networks:
      - goodreads
networks:
  goodreads:


The first service is the MongoDB database container loaded with Book data. To configure it, we have the container name, so we can reference and identify the container by name. The image field specifies whether we want to use an existing image (as we have done here) or build a new image.

Note: I am running on Apple silicon architecture. If you are not, you will need to build your own version of the image with the instructions provided on GitHub. This will build the container on your local architecture.

The next field sets environment variables for connecting to the database with the provided credentials (username and password). Specifying ports comes next, where we map the host post to the container port, allowing traffic to flow between our local machine and the container via the same port number. Note: It is recommended to enclose the port field values with quotes, as shown.

Then, we need to add the container to our custom goodreads network we set up earlier. The final subfield is to mount volumes from the local machine to the container, allowing the database to store the data files in a permanent place so that the loaded data does not disappear when the container shuts down. Instead, each time the container spins up, the data is already there, and each time it spins down, any changes are stored for the next startup.

Let’s look at goodreads-config next.

Spring Cloud Config Service

YAML
 
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


Tacking onto our list of services is the configuration server. Just like our database service, we specify the container name and image. We could also substitute the build field (next field that is commented out) for the image field, if we wanted to build the container locally, rather than using a pre-built image. The ports field comes next to map internal and external container ports, followed by the depends_on field, which 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. We cap off the options by adding the container to our goodreads Docker network.

Moving to our numbered services!

Spring Boot API Microservice - MongoDB (Books)

YAML
 
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


I found this piece to be the toughest one to get working because there were a couple of quirks when interacting with the config service in Docker Compose. This was mostly due to startup order and timing of services with Docker Compose. Let’s walk through it.

The first four fields are the same as with previous services (container name, image/build, ports, and depends on), although service1 actually depends on the config service and not the database container directly. This is because the config service supplies the database credentials, so service1 cannot call the database without the config service providing credentials to access the database. Plus, since the config service relies on the database, then service1 can rely on the config service, creating a dependency chain without too much complexity.

The next field for restart is new, though. Earlier, I mentioned that depends_on only waits for the container to start, not for the service to be ready. Service1 would start too early and 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. 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 by automatically restarting the container when the application fails.

The following environment variable option specifies 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. These properties also did not work correctly if I put them in the config file itself. The values had to be accessible within the container, or it would not know where to look. Lastly, we add this container to our custom network.

With this core application service added to the mix, services two through four are a bit easier.

Spring Boot Client Microservice

YAML
 
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


The fields should start to seem pretty familiar. We use the container name, image (or build), ports, depends on, and restart options like we did before. The environment option contains a new value for dynamically plugging in a value for the WebClient bean to find the API service (service1). For more background on this feature, check out this blog post. We wrap up with our custom network assignment and charge on to service3!

Spring Boot API Microservice - MongoDB (Authors)

YAML
 
version: "3.9"
services:
  #goodreads-db...
  #goodreads-config...
  #goodreads-svc1...
  #goodreads-svc2...
  goodreads-svc3:
    container_name: goodreads-svc3
    image: jmreif/goodreads-svc3:lvl9
    # build: ./service3
    ports:
      - "8082:8082"
    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


This service’s configuration looks nearly identical to that of service1 because they are both providing the same function within the microservices system - an API backing service. The big difference is that service1 is handling Book data objects, and service3 is handling Author data objects. Otherwise, they both use the config service for database credentials to the MongoDB container and also both depend on the config service.

Let’s dive into service4!

Spring Boot API Microservice - Neo4j (Reviews)

YAML
 
version: "3.9"
services:
  #goodreads-db...
  #goodreads-config...
  #goodreads-svc1...
  #goodreads-svc2...
  #goodreads-svc3...
  goodreads-svc4:
    container_name: goodreads-svc4
    image: jmreif/goodreads-svc4:lvl9
    # build: ./service4
    ports:
      - "8083:8083"
    depends_on:
      - goodreads-config
    restart: on-failure
    environment:
      - SPRING_APPLICATION_NAME=neo4j-client
      - SPRING_CONFIG_IMPORT=configserver:http://goodreads-config:8888
      - SPRING_PROFILES_ACTIVE=docker
    networks:
      - goodreads


This service also mimics services 1 and 3 because it is the API service, but it interacts with a cloud-hosted Neo4j database. The configuration looks nearly identical, except that the environment variable for the application name references the config file containing Neo4j database credentials (versus MongoDB credentials).

Time to put everything to the test!

Put it to the Test

Docker compose will handle starting all of the containers in the proper order, so all we need to do is assemble the command.

Shell
 
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.

The containers should spin up, and we can verify them with docker ps. Next, we can test all of our endpoints.

Note: Showing default profile to hide sensitive values.

Note: Showing `default` profile to hide sensitive values.

Bring everything back down again with the below command.

Shell
 
docker-compose down

Wrapping Up!

We have successfully created an orchestrated microservices system with Docker Compose!

This post tackled Docker Compose for managing microservices together based on information we specify in the docker-compose.yml file. We also covered some tricky "gotchas" with Docker networks and environment issues related to startup order and dependencies between microservices, but we were able to navigate those with configuration options.

There is so much more we can explore with microservices, such as adding more data sources, additional services, asynchronous communication through messaging platforms, cloud deployments, and more. I hope to catch you in future improvements on this project. Happy coding!

Resources

 

 

 

 

Top