Service Discovery

Resolve logical service names like https+http://catalog to real endpoints from configuration, Aspire or Kubernetes DNS.

Syntaxbuilder.Services.AddServiceDiscovery(); builder.Services.ConfigureHttpClientDefaults(h => h.AddServiceDiscovery()); builder.Services.AddHttpClient<CatalogClient>(h => h.BaseAddress = new("https+http://catalog"));

Microsoft.Extensions.ServiceDiscovery (10.11.0) lets code address another service by a logical name and resolves the real endpoint at request time. A typed client sets BaseAddress = new Uri("https+http://catalog"); a handler added by AddServiceDiscovery() rewrites each request to a concrete address such as https://10.42.0.17:8443. The same code then works on a laptop, under the Aspire AppHost and in Kubernetes, with only configuration changing.

Wiring it up

Two calls: builder.Services.AddServiceDiscovery() registers the resolver with a configuration-based provider and a pass-through provider, and AddServiceDiscovery() on an HTTP client builder, usually inside ConfigureHttpClientDefaults, adds the resolving handler to clients. Aspire's service defaults project does both.

Name syntax

URIMeaning
http://catalogThe http endpoints of service catalog
https+http://catalogPrefer https endpoints; use http only if none exist
https://_grpc.catalogThe endpoint named grpc of catalog, for a service exposing several ports

Configuration provider

Endpoints come from the Services section: Services:catalog:https:0 = https://localhost:7101. In environment variables that is services__catalog__https__0, which is exactly what the Aspire AppHost injects into a project for each WithReference. Several entries form a list and the resolver spreads requests across them. Resolved endpoints are cached and refreshed (every 60 seconds by default, ServiceDiscoveryOptions.RefreshPeriod).

In Kubernetes

When no provider has endpoints for a name, the pass-through provider hands the host name to DNS: http://catalog becomes a request to host catalog on port 80, which inside the cluster resolves to the catalog Service in the same namespace. Many teams therefore need no service discovery configuration in the cluster at all, or set services__catalog__https__0=https://catalog.shop.svc.cluster.local:8443 to be explicit. Remember that a ClusterIP Service balances connections, not requests: with keep-alive and HTTP/2, a client stays pinned to the pods it first connected to until connections recycle, which is one more reason to keep handler lifetimes bounded. Headless Services and DNS SRV lookups are available through the separate Microsoft.Extensions.ServiceDiscovery.Dns package.

Security and failure modes

  • https+http silently falls back to plain HTTP when only an http endpoint is configured. In production, set AllowAllSchemes = false and AllowedSchemes = ["https"] in ServiceDiscoveryOptions; setting AllowedSchemes alone has no effect while AllowAllSchemes is true, its default. The restriction applies to every name, not only https+http ones: with https only, http://inventory no longer resolves, so serve https in the cluster too. Where pod-to-pod HTTP is deliberate (a mesh doing mTLS), use plain http:// names and leave schemes unrestricted.
  • A misspelt or missing Services entry is not an error: it falls through to DNS and fails as "name or service not known" for host catalog, which looks like a network problem.
  • Service discovery is not load balancing or health checking. A configured endpoint that is down stays in the list; resilience handlers and the platform's readiness probes cover that.

Example

Example · csharp
using Microsoft.Extensions.ServiceDiscovery;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http => http.AddServiceDiscovery());

if (!builder.Environment.IsDevelopment())
{
    // Never let https+http fall back to plain HTTP outside a developer machine.
    builder.Services.Configure<ServiceDiscoveryOptions>(options =>
    {
        options.AllowAllSchemes = false;
        options.AllowedSchemes = ["https"];
    });
}

builder.Services.AddHttpClient<CatalogClient>(http =>
{
    http.BaseAddress = new Uri("https+http://catalog");
    http.Timeout = TimeSpan.FromSeconds(5);
});

var app = builder.Build();

app.MapGet("/orders/{orderId:guid}/lines", async (Guid orderId, CatalogClient catalog, CancellationToken ct) =>
{
    string[] skus = ["KETTLE-BLUE-1L", "FILTER-PACK-6"];
    var products = await catalog.GetProductsAsync(skus, ct);
    return Results.Ok(new { orderId, products });
});

app.Run();

public sealed class CatalogClient(HttpClient http)
{
    public async Task<Product[]> GetProductsAsync(IEnumerable<string> skus, CancellationToken ct)
    {
        var query = string.Join("&", skus.Select(s => $"sku={Uri.EscapeDataString(s)}"));
        return await http.GetFromJsonAsync<Product[]>($"/api/products?{query}", ct) ?? [];
    }
}

public sealed record Product(string Sku, string Name, decimal Price);

When to use it

  • Run the orders API against a local catalog on localhost:7101 and against catalog.shop.svc.cluster.local in Kubernetes without changing code.
  • Let the Aspire AppHost inject the inventory service's address into the orders API through WithReference.
  • Refuse to call the payment service over plain HTTP in production even though the client uses https+http://payments.

More examples

Endpoints for local development

Each scheme key holds a list of endpoints; with two inventory instances the resolver alternates between them. A key that is not a scheme, like <code>grpc</code>, is a named endpoint and is addressed as <code>http://_grpc.inventory</code>. When the Aspire AppHost runs the app, these values come from injected environment variables instead and this file is not needed.

Example · json
{
  // appsettings.Development.json: the catalog runs on the developer's machine.
  "Services": {
    "catalog": {
      "https": [ "https://localhost:7101" ],
      "http": [ "http://localhost:5101" ]
    },
    "inventory": {
      "http": [ "http://localhost:5201", "http://localhost:5202" ],
      "grpc": [ "http://localhost:5299" ]
    }
  }
}

Explicit endpoints in a Kubernetes Deployment

Double underscores map to the <code>Services:catalog:https:0</code> keys, and configuration keys are case-insensitive, so the lower-case <code>services</code> prefix the AppHost uses works too. Fully qualified Service names make the target namespace explicit, which avoids a surprise when the same image is deployed into another namespace that also has a Service called <code>catalog</code>. Both endpoints are https because the production resolver allows only that scheme for every name; an <code>http</code> entry here would fail to resolve.

Example · yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
  namespace: shop
spec:
  replicas: 3
  selector:
    matchLabels:
      app: orders-api
  template:
    metadata:
      labels:
        app: orders-api
    spec:
      containers:
        - name: api
          image: registry.example.com/shop/orders-api:2.3.0
          ports:
            - containerPort: 8080
          env:
            - name: services__catalog__https__0
              value: https://catalog.shop.svc.cluster.local:8443
            - name: services__inventory__https__0
              value: https://inventory.shop.svc.cluster.local:8443

Logging what each name resolves to at start-up

<code>ServiceEndpointResolver</code> is the service the HTTP handler uses, so this shows exactly where requests will go. A name that prints as <code>Unspecified/catalog:80</code> came from the pass-through provider, meaning no configuration matched; that one log line turns a confusing DNS error in production into an obvious configuration mistake.

Example · csharp
using Microsoft.Extensions.ServiceDiscovery;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http => http.AddServiceDiscovery());

var app = builder.Build();

var resolver = app.Services.GetRequiredService<ServiceEndpointResolver>();
string[] dependencies = ["https+http://catalog", "https+http://inventory", "https+http://_grpc.inventory"];
foreach (var name in dependencies)
{
    var source = await resolver.GetEndpointsAsync(name, CancellationToken.None);
    app.Logger.LogInformation("{Service} resolves to {Endpoints}",
        name, string.Join(", ", source.Endpoints.Select(e => e.EndPoint.ToString())));
}

app.Run();

Discussion

  • Be the first to comment on this lesson.