DEV Community

Cover image for Lo-Fi Service Discovery in .NET8
David Whitney
David Whitney

Posted on

Lo-Fi Service Discovery in .NET8

The vast majority of systems that you build will inevitably call a HTTP API at some point. Whether it's a microservice, a third party API, or a legacy system. Because of this, it's not uncommon to see applications with reams of configuration variables defining where their downstream dependencies live.

This configuration is frequently a source of pain and duplication, especially in larger systems where tens or hundreds of components need to keep track of location of downstream dependencies, many of which are shared, and almost all of which change depending on deployment environment.

These configuration values get everywhere in your codebases, and often are very difficult to coordinate changes to when something changes in your deployed infrastructure.

Service discovery to the rescue

Service Discovery is a pattern that aims to solve this problem by providing a centralised location for services to register themselves, and for clients to query to find out where they are. This is a common pattern in distributed systems, and is used by many large scale systems, including Netflix, Google, and Amazon.

Service registries are often implemented as a HTTP API, or via DNS records on platforms like Kubernetes.

Service Discovery

Service discovery is a very simple pattern consisting of:

  • A service registry, which is a database of services and their locations
  • A client, which queries the registry to find out where a service is
  • Optionally, a push mechanism, wh ich allows services to notify clients of changes

In most distributed systems, teams tend to use infrastructure as code to manage their deployments. This gives us a useful hook, because we can use the same infrastructure as code to register services with the registry as we deploy the infrastructure to run them.

Service discovery in .NET8 and .NET Aspire

Example

.NET 8 introduces a new extensions package - Microsoft.Extensions.ServiceDiscovery - which is designed to interoperate with .NET Aspire, Kubernetes DNS, and App Config driven service discovery.

This package provider a hook to load service URIs from App Configuration json files, and subsequently to auto-configure HttpClient instances to use these service URIs. This allows you to use service names in the HTTP calls in your code, and have them automatically resolved to the correct URI at runtime.

This means that if you're trying to call your foo API, that instead of calling

var response = await client.GetAsync("http://192.168.0.45/some-api");
Enter fullscreen mode Exit fullscreen mode

You can call

var response = await client.GetAsync("http://foo/some-api");
Enter fullscreen mode Exit fullscreen mode

And the runtime will automatically resolve the service name foo to the correct IP address and port.

This runtime resolution is designed to work with the new Aspire stack, which manages references between different running applications to make them easier to debug, but because it has fallback hooks to App Configuration which means it can be used with anything that can load configuration settings.

Here's an example of a console application in C# 8 that uses these new service discovery features:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

// Register your appsettings.json config file
var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
    .Build();

// Create a service provider registering the service discovery and HttpClient extensions
var provider = new ServiceCollection()
    .AddServiceDiscovery()
    .AddHttpClient()
    .AddSingleton<IConfiguration>(configuration)
    .ConfigureHttpClientDefaults(static http =>
    {
        // Configure the HttpClient to use service discovery
        http.UseServiceDiscovery();
    })
    .BuildServiceProvider();

// Grab a new client from the service provider
var client = provider.GetService<HttpClient>()!;

// Call an API called `foo` using service discovery
var response = await client.GetAsync("http://foo/some-api");
var body = await response.Content.ReadAsStringAsync();

Console.WriteLine(body);
Enter fullscreen mode Exit fullscreen mode

If we pair this with a configuration file that looks like this:

{
  "Services": {
    "foo": [
      "127.0.0.1:8080"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

At runtime, when we make our API call to http://foo/some-api, the HttpClient will automatically resolve the service name foo to 127.0.0.1:8080. For the sake of this example, we've stood up a Node/Express API on port 8080. It's code looks like this:

const express = require('express');
const app = express();
const port = 8080;

app.get('/some-api', (req, res) => res.send('Hello API World!'));
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Enter fullscreen mode Exit fullscreen mode

So now, when we run our application, we get the following output:

$ dotnet run
Hello API World!
Enter fullscreen mode Exit fullscreen mode

That alone is pretty neat - it gives us a single well known location to keep track of our services, and allows us to use service names in our code, rather than having to hard code IP addresses and ports. But this gets even more powerful when we combine it with a mechanism to update the configuration settings the application reads from at runtime.

Using Azure App Configuration Services as a service registry

Azure App Configuration Services provides a centralised location for configuration data. It's a fully managed service, and consists of Containers - a key/value stores that can be used to store configuration data.

App Configuration provides a REST API that can be used to read and write configuration data, along with SDKs and command line tools to update values in the store.

When you're using .NET to build services, you can use the Microsoft.Extensions.Configuration.AzureAppConfiguration package to read configuration data from App Configuration. This package provides a way to read configuration data from App Configuration Services, integrating neatly with the IConfiguration API and ConfigurationManager class.

If you're following the thread, this means that if we enable service discovery using the new Microsoft.Extensions.ServiceDiscovery package, we can use our app config files as a service registry. If we combine this extension with Azure App Configuration Services and it's SDK, we can change one centralised configuration store and push updates to all of our services whenever changes are made.

This is really awesome, because it means if you're running large distributed teams, so long as all the applications have access to the configuration container, they can address each other by service name, and the service discovery will automatically resolve the correct IP address and port, regardless of environment.

Setting up Azure App Configuration Services

You'll need to create an App Configuration Service. You can do this by going to the Azure Portal, and clicking the "Create a resource" button. Search for "App Configuration" and click "Create".

Create App Configuration Service

For the sake of this example, we're going to grab a connection string from the portal, and use it to connect to the service. You can do this by clicking on the "Access Keys" button in the left hand menu, and copying the "Primary Connection String". You'd want to use RBAC in a real system.

We're going to add an override by clicking "Configuration Explorer" in the left hand menu, and adding a new key called Services:foo with a value of:

[
    "value-from-app-config:8080"
]
Enter fullscreen mode Exit fullscreen mode

and a content type of application/json.

Setting up the Azure App Configuration SDK

We need to add a reference to the Microsoft.Extensions.Configuration.AzureAppConfiguration package to access this new override. You can do this by running the following command in your project directory:

dotnet add package Microsoft.Extensions.Configuration.AzureAppConfiguration
Enter fullscreen mode Exit fullscreen mode

Next, we modify the configuration bootstrapping code in our command line app.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var appConfigConnectionString = "YOUR APP CONFIG CONNECTION STRING HERE";

var configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
    .AddAzureAppConfiguration(appConfigConnectionString, false) // THIS LINE HAS BEEN ADDED
    .Build();
Enter fullscreen mode Exit fullscreen mode

This adds our Azure App Configuration as a configuration provider.

Nothing else in our calling code needs to change - so when we execute our application, you'll notice that the call now fails:

$ dotnet run
Unhandled exception. System.Net.Http.HttpRequestException: No such host is known. (value-from-app-config:8080)
 ---> System.Net.Sockets.SocketException (11001): No such host is known.
   at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.ThrowException(SocketError error, CancellationToken cancellationToken)
   at System.Net.Sockets.Socket.AwaitableSocketAsyncEventArgs.System.Threading.Tasks.Sources.IValueTaskSource.GetResult(Int16 token)
   at System.Net.Sockets.Socket.<ConnectAsync>g__WaitForConnectWithCancellation|285_0(AwaitableSocketAsyncEventArgs saea, ValueTask connectTask, CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, HttpRequestMessage initialRequest, Boolean async, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
   ...
Enter fullscreen mode Exit fullscreen mode

If you look at the error message carefully it's now trying to connect to value-from-app-config:8080 - which is the value we put in our App Configuration container.

In a real-world scenario, you would want to configure refreshing of the configuration values following the guides available here.

Populating values in the real world

We've gone into detail about how you can configure service discovery using a combination of the new Microsoft.Extensions.ServiceDiscovery package, and the Microsoft.Extensions.Configuration.AzureAppConfiguration package, along with an Azure App Configuration Service Container - but this is all useless if you can't populate the values in the first place.

Unfortunately this entirely depends on how you automate your deployments. But in principle, you can use the Azure App Configuration SDK or API to populate the values in the container. You'll likely want to do this when your infrastructure as code runs (Pulumi/Bicep/Terraform), or as part of your CI/CD pipeline.

As part of these updates, I'd also recommend hashing all the values and adding a checksum key into the configuration store. This will allow you to monitor a single key from the client side SDKs, and trigger a refresh when the checksum changes.

If you're still working on automating your infrastructure, this technique can still be useful as you can use the portal itself to update the values in the container, and the SDK will automatically pick up the changes.

Conclusion

While a lot of the experiences being built for Aspire have limited value for larger distributed systems, I think this is an excellent example of how we can use some of the low-level features of the Aspire stack to build useful tools for other use-cases.

While we've focused purely on config file driven service discovery in this piece, you can implement custom resolvers to use other service registries like Hashicorp Consul, or another home grown solution.

Top comments (4)

Collapse
 
mdit profile image
Matt Davidson

Looks handy, presumably you can store tokenised api uri’s in configuration rather than hard coding them, as in your GetAsync examples, and they can be resolved at runtime using service discovery?

Collapse
 
david_whitney profile image
David Whitney

Exactly that :)

Collapse
 
glntn101 profile image
Luke

Good article, thanks!

Collapse
 
margepour profile image
Marge Pour

This is pretty cool, thanks for sharing!