.NET Microservices : Service Discovery (Part 3: Configuring Commands HTTP Service to Consul and consuming it)

Hello again on this third part of our Service Discovery tutorial for .NET core Microservices.

If you haven't seen the beginning of the series I recommand you check the first post about Consul & RabbitMQ and the second post about .NET code to access services with Consul, along with the base course from Les Jackson.

This post is part of a whole:

We successfully deployed Consul to register our Microservices, we set up RabbitMQ to register automatically to Consul, and we updated our Microservices to call Consul in order to get the RabbitMQ configuration dynamically.

Several services such as RabbitMQ offer some elements or plugins to configure automatically to Consul, I strongly recommand to check out before doing anything complicated because as you've seen, RabbitMQ was quite easy to set up.

So what do we want to do now? Well the whole point was to remove the dependency between our microservices, so we are going to register one of the services to Consul and ask Consul to deliver the configuration to whomever needs it at runtime.

I used some parts of another of TechyMaki's video about Consul, find out more here:

We are going to register the CommandService HTTP Service first, simply because it's the most trivial to configure and to call.

Registering our Commands Service to Consul

Configuring our appsettings

Let's focus on CommandService project.
First things first, we are going to need to configure a few things for our appsettings files. The logic here will be reversed, instead of configuring the addresses and ports of the other services, we're going to configure our own address, port and consul info (id & name).

When we wanted to call CommandService from PlatformService, we configured the following address locally like that: "CommandsService": "http://localhost:6000" in the PlatformService project.

Now, we want to configure the two appsettings of CommandService this way:

{
  (…)
  "CommandsHttp": {
    "Id": "commandshttp",
    "Name": "commandshttp",
    "Address": "localhost",
    "Port": "6000"
  }
}
appsettings.Development.json
{
  (…)
  "CommandsHttp": {
    "Id": "commandshttp",
    "Name": "commandshttp",
    "Address": "commands-clusterip-srv",
    "Port": "80"
  }
}
appsettings.Production.json

Locally, our server is running in localhost on port 6000, which is what we configured.
In our kubernetes cluster, we configured our service to run on the commands-clusterip-srv address, with a classic port of 80.

I chose commandshttp as Id & Name for Consul and we're going to use it now.

Hosted Service

We are going to use a HostedService. The principle is pretty simple, you implement an interface (IHostedService) that has two methods, StartAsync() and StopAsync(), and we will register this class to our AspNet startup system.

The Hosted Service will trigger the StartAsync() method just before starting the application, and will trigger StopAsync() right before the application stops. Remember this is sequentially triggered at startup so if you need a hosted service, you should keep these two implementation minimal not to overload your overall system.

This is actually perfect because it's the exact lifecycle we want for our Consul Registration: register the service at startup, deregister it at shutdown. Life is good.

ConsulRegisterHostedService

We are going to create a new class, ConsulRegisterHostedService that we will place in the same folder as the rest of our Consul Services (DiscoveryServices). It will implement the IHostedService interface. We will need the IConsulClient we injected before and the IConfiguration to get our configuration. It should look like that at first:

using Consul;

namespace CommandsService.DiscoveryServices;

public class ConsulRegisterHostedService : IHostedService
{
    public ConsulRegisterHostedService(IConsulClient consulClient, 
                                       IConfiguration configuration)
    {
        _consulClient = consulClient;
        _configuration = configuration;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("Registering service to Consul");
		return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        Console.WriteLine("Deregistering service to Consul");
        return Task.CompletedTask;
    }
}
ConsulRegisterHostedService before implementing registration

Registering our Hosted Service

Now we're going to register our Hosted Service. It's pretty standard, just go to your Program.cs (or Startup.cs) and use the services.AddHostedService<T>() method:

var services = builder.Services;

(…)

services.AddHostedService<ConsulRegisterHostedService>();

(…)
Program.cs

Just start your application with dotnet run to check you have the register log in the console, then kill it with CTRL+C and check you have the deregister log like that:

Registering & Deregistering logs

StartAsync() and StopAsync() implementations

The implementation is quite simple, Consul has everything ready for us for registration and deregistration with the AgentServiceRegistration class. We're going to register in StartAsync() and deregister on StopAsync(). Side-note, just as a security we're going to deregister the existing id before registering it.

We're going to use everything we put in our appsettings, just like that in StartAsync():

public async Task StartAsync(CancellationToken cancellationToken)
{
    Console.WriteLine("Registering service to Consul");

    var serviceRegistration = new AgentServiceRegistration()
    {
        Address = _configuration["CommandsHttp:Address"],
        Port = int.Parse(_configuration["CommandsHttp:Port"]),
        Name = _configuration["CommandsHttp:Name"],
        ID = _configuration["CommandsHttp:Id"]
    };

    try {
        await _consulClient.Agent.ServiceDeregister(serviceRegistration.ID, cancellationToken);
        await _consulClient.Agent.ServiceRegister(serviceRegistration, cancellationToken);
    }
    catch (Exception ex) {
        Console.WriteLine($"Unable to register to Consul: {ex.Message}");
    }
}
ConsulRegisterHostedService StartAsync() implementation

And for the StopAsync() implementation:

public async Task StopAsync(CancellationToken cancellationToken)
{
    Console.WriteLine("Deregistering service to Consul");
    try {
        await _consulClient.Agent.ServiceDeregister(_configuration["CommandsHttp:Id"], cancellationToken);
    }
    catch (Exception ex) {
        Console.WriteLine($"Unable to deregister to Consul: {ex.Message}");
    }
}
ConsulRegisterHostedService StartAsync() implementation

Pretty straightforward, just put a try-catch block (especially on startup) because something could go wrong and we don't want a HostedService to fail ungracefully. For instance, if Consul server was not available.

One improvement could be a retry-mechanism in case Consul is not available; we're not going to bother here but this might be a good idea in production to avoid orphaned services.

Now check that you correctly register your service in your Consul management page (http://localhost:8500):

Consul Services
CommandsHttp is configured to localhost:6000

Now kill you app and check you no longer have the service; it should be good!
If you stay on the page you should have an error message:

Service deregistered at runtime

Congratulations!

Kubernetes build

Last step for our CommandService configuration, rebuild, push and restart your deployment:
docker build -t xxx/commandservice .
docker push xxx/commandservice
kubectl rollout restart deployment commands-depl

Now check that your service is correctly registered with the production address:

CommandsHttp with production address

All set regarding CommandService HTTP configuration to Consul! Not that difficult right?

Getting CommandsHttp configuration from Consul

Okay now let's get back to our PlatformService, trust me this will go very smoothly from now on due to our configuration on the previous post.

First, you can remove the "CommandsService" parameter from all the appsettings (dev & prod): we won't need it anymore!

Get back to ConsulService.cs, we're going to configure our new id (commandshttp):

namespace PlatformService.DiscoveryServices;

public static class ConsulServices 
{
    public const string RabbitMQ = "rabbitmq";
    public const string CommandsHttp = "commandshttp";
}
ConsulService.cs

Now get back to our HttpCommandDataClient, we're going to replace what is no longer needed with our Consul service call. What we currently have:

using System.Text;
using System.Text.Json;
using PlatformService.Dto;

namespace PlatformService.SyncDataServices.Http;

public class HttpCommandDataClient : ICommandDataClient
{
    private readonly HttpClient _httpClient;
    private readonly IConfiguration _configuration;

    public HttpCommandDataClient(HttpClient httpClient, IConfiguration configuration)
    {
        _httpClient = httpClient;
        _configuration = configuration;
        Console.WriteLine($"Configuration is on {_configuration["CommandsService"]}");
    }

    public async Task SendPlatformToCommandAsync(PlatformReadDto platform)
    {
        var httpContent = new StringContent(
            JsonSerializer.Serialize(platform),
            Encoding.UTF8,
            "application/json"
        );
        var response = await _httpClient.PostAsync($"{ _configuration["CommandsService"] }/api/c/platforms", httpContent);

        if (response.IsSuccessStatusCode)
        {
            Console.WriteLine("Success !");
        }
        else
        {
            Console.WriteLine("Error during Sending Platform to command service");
        }
    }
}
HttpCommandDataClient.cs before

So the two steps are

  • Remove IConfiguration and add IConsulRegistryService, just like we did for RabbitMQ previously
  • Replace our configuration hard-coded values with a consul service call

I'll skip the constructor and private fields to focus on the SendPlatformToCommandAsync which should look something like this:

public async Task SendPlatformToCommandAsync(PlatformReadDto platform)
{
    try
    {
        var service = _consulRegistryService.GetService(ConsulServices.CommandsHttp);
        if (service == null)
            throw new ArgumentNullException(nameof(service));

        var httpContent = new StringContent(
            JsonSerializer.Serialize(platform),
            Encoding.UTF8,
            "application/json"
        );
        var response = await _httpClient.PostAsync($"http://{service.Address}:{service.Port}/api/c/platforms", httpContent);

        if (response.IsSuccessStatusCode)
        {
            Console.WriteLine("Success !");
        }
        else
        {
            Console.WriteLine("Error during Sending Platform to command service");
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error during Sending Platform to command service: {ex.Message}");

    }
}
HttpCommandDataClient.cs

Now start your CommandService locally with dotnet run, check it is correctly registered, start your PlatformService with dotnet run and check that the CommandHttp is correctly found and called; you're all set!

All that is left is to just build/push/rollout your image for platformservice and check that everything runs correctly.

And that's it!

Additional side-notes

First of all, we implemented our IConsulRegistryService.GetService() to be synchronous with a « dirty » var serviceQueryResult = _consulClient.Health.Service(serviceName).Result;. It was necessary considering we were using it in a constructor, but that is quite a waste when we're using it in an asynchronous method such as SendPlatformToCommandAsync(). If you want to go to production, I would refactor this a bit.

Secondly, we could wonder why we're calling our _consulRegistryService each time. It seems quite a waste of time: we could just load it in the constructor and keep it safe in a field.
BUT…
That would defeat the whole purpose of the operation! In that ecosystem, the services come and go, live and die, and we want to have the most accurate configuration possible, so we NEED to call the service each time actually to be sure to have a service that is alive and healthy at this precise moment, not during initialization.

An additional implementation could be to listen to the updates from Consul in the ConsulRegistryService to keep a track of the services, it could save a few calls to Consul. I think it might be overkill but I'd like your opinion on this !

Thank you for reaching the end of the part 3 of that tutorial and I hope that you enjoyed it!
Right now you should have nearly all the keys to switch to a fully functional Service Discovery system in your Microservices!

Next up we're going to do something similar with gRPC, with just a little configuration twist that deserves its own post in my opinion.

See you!

This post is part of a whole: