When working with our clients to design solutions using a microservices architecture, we often encounter the requirement for quick and easy management of the entire system and the highest possible degree of automation to avoid having to adjust individual components.
This is a real challenge, which is why we prepared a tutorial that demonstrates the simplest possible way to establish a .NET-based microservices architecture that can be quickly and very easily scaled and adapted to client requirements.
We did not want to have to make any changes to the code or settings of individual services but control the system just by orchestrating containers in Docker.
The result is a simple microservices architecture that can be easily scaled with just a few changes in container settings. The scaling of the application is handled by two open-source components: Ocelot, which is a gateway and load balancer, and HashiCorp Consul*, the identity-based network service which acts as a service discovery agent.
Such an architecture allows us to redeploy multiple instances of a single service without coordinating the deployment with other services. The redeployed service instances are automatically registered for service discovery and immediately available through the gateway. You can imagine how big a boost this is for any development team!
Of course, using a single gateway service becomes a single point of failure in our architecture, so we need to deploy at least two instances of it to have high availability, but we will leave that problem for you to play with.
THE CONSUL SERVICE
A key part of this tutorial is the use of the Consul service to dynamically discover service endpoints. Consul automatically manages a service registry, which is updated when any new instance of a service is registered and becomes available to receive traffic. Once a service is registered with Consul, it can be discovered using the standard DNS mechanism or via a custom API. This helps us to easily scale our services.
If we are running multiple instances of the same service, Consul will randomly distribute traffic across the different instances, balancing the load between them.
Consul also provides health checks on service instances registered with it. If one of the services fails its health check, the registry will recognise this condition and will avoid returning that service’s address to clients looking up the service.
The first step is for a service instance to register itself with the service discovery service by providing its name, ID, and address. Then the gateway is able to retrieve the address of this service by querying the Consul service discovery API using the name or ID of the service.
The key thing to note here is that the service instances are registered with a unique service ID in order to differentiate between the various instances of a service which are running on the same Consul service agent. Each service must have a unique ID per node, so if their names were to conflict (as in our case), then the unique IDs allow each one to be unambiguously identified.
ARCHITECTURE OF OUR TUTORIAL APPLICATION
Our tutorial uses three instances of a very simple microservice, which just returns the request URL and ID, and a single gateway microservice (Ocelot) to provide an API to external clients. All of the services, including Consul, are containerised with Docker, based on lightweight GNU/Linux distributions of their base container images.
IMPLEMENTATION OF OUR TUTORIAL APPLICATION
Let’s look at how we can implement self-registration in the .NET application. First, we need to read the configuration required for service discovery from environment variables that were passed through the docker-compose.override.yml file.
After reading the configuration required to reach the service discovery service, we can use it to register our service. The code below is implemented as a background task (i.e. a hosted service) that registers our service in Consul by overriding any previous information about the service. If the service is shutting down, it is automatically unregistered from the Consul registry.
Finally, we need to register our configuration and hosted service, with its Consul dependencies, to the dependency injection container. To do this, we use a simple extension method that can be shared within our services:
Once we have registered our services in the service discovery service, we can start implementing the API gateway.
Creating an API gateway using Ocelot
Ocelot requires that you provide a configuration file that contains a list of Routes (configuration used to map upstream requests to API endpoints) and a GlobalConfiguration (other configuration settings like QoS, rate-limiting parameters, etc.).
In the ocelot.json file below, you can see how we forward HTTP requests. We have to specify which type of load balancer we will use. In our case, this is a RoundRobin which loops through the available services and sends requests to them.
It is important to set Consul as a service discovery service in the GlobalConfiguration for the ServiceDiscoveryProvider.
Some of the more important ServiceDiscoveryProvider settings in the GlobalConfiguration section are as follows:
- Host – the host of Consul
- Port – the port of Consul
- Type Consul – means that Ocelot will get service information from Consul per request
- Type PollConsul – means that Ocelot will poll Consul for the latest service information
- PollingInterval – tells Ocelot how often to call Consul for changes in the service registry
After we have defined our configuration, we can start to implement an API gateway based on .NET 5 and Ocelot. Below, you can see the implementation of an Ocelot API gateway service that uses our ocelot.json configuration file and Consul as a service registry.
The Program class contains the method Main(), which is the entry point of the .NET applications. The Program class also creates the web host upon startup.
The Startup class configures the application’s services and defines the middleware pipeline. In this step, it is important to include the AddConsul middleware in the pipeline using the AddConsul extension:
Running the services in Docker
As mentioned earlier, we will containerise all of the services, including Consul, with Docker, using lightweight GNU/Linux distributions for the base container images.
For this, we first need to set up our docker-compose.yml, the config file for Docker Compose. It allows us to deploy, combine, and configure multiple Docker containers at the same time. In our tutorial, it looks like this:
Note that our services don't contain any configuration files, as we are going to use the docker-compose.override.yml file to provide configuration values. In this configuration file, you can override existing settings from docker-compose.yml or even add completely new services. In our tutorial, it looks like this:
Starting the containers
To execute the Docker Compose file and start the containers, open Powershell and navigate to the compose file in the root folder. Then execute the following command: docker-compose up -d -build, which starts and runs all of the containers. The -d parameter executes the command as a “detached” command. This means that the containers run in the background and don’t block your Powershell window. To check the running containers, use the command docker ps.
Consul Web UI
Consul offers a nice web user interface right out of the box. You can access it on port 8500 (http://localhost:8500). Let’s look at some of the screens.
The home page for the Consul UI services, showing all of the relevant information related to a Consul agent and web service check, is shown below.
From these screens, we can see that Consul is providing us with a service registry and performing regular health checks on the services we have registered with it.
Check it out
Let’s make several calls through the API gateway at http://localhost:9500/api/values. The load balancer will loop through the available service instances, forward requests to them, and return the responses that they produce:
You can now see the architecture in action, with the load balancer distributing the incoming requests across the available service instances.
Microservice systems are often not easy to build and maintain. However, this tutorial showed how easy it is to develop and deploy an application with a .NET microservices architecture.
As we have seen, Consul provides first-class support for service discovery, health checks, key-value storage for configuration items, and multi-data centre deployments. Ocelot can be used to provide an API gateway that communicates with the Consul service registry and retrieves service registrations, while the load balancer distributes load across a group of service instances by looping through the available services and forwarding requests to them.
Using both makes life significantly easier for developers facing such challenges. Wouldn’t you agree?
Find source code for this tutorial on Endava’s GitHub.
TAGS & TECHNOLOGIES
* Consul® is the registered trademark of HashiCorp.