Kubernetes does not always contain all of your workloads.

A platform may run most applications inside Kubernetes while still depending on:

  • legacy applications running on virtual machines
  • application servers that cannot yet be migrated
  • databases hosted outside the cluster
  • internal appliances
  • workloads running on bare metal
  • services living in another infrastructure environment

The networking problem is usually not difficult.

If the cluster can route to an external IP, Kubernetes workloads can reach it.

The operational problem appears later:

Who keeps Kubernetes synchronized
when those external endpoints change?

That is the problem I wanted to solve with External Service Discovery Operator.

It is a small Kubernetes controller that discovers external workloads and projects them into native:

Service
+
EndpointSlice

resources.

No proxy.

No sidecar.

No data plane.

No service mesh.

Just discovery translated into Kubernetes-native networking resources.


The obvious question: why not ExternalName?

Kubernetes already has:

apiVersion: v1
kind: Service
metadata:
  name: legacy-api
spec:
  type: ExternalName
  externalName: legacy-api.internal.example.com

For many workloads, this is exactly the right solution.

An ExternalName Service effectively provides:

legacy-api.default.svc.cluster.local
                โ†“
legacy-api.internal.example.com
                โ†“
DNS resolution

If all you need is a Kubernetes DNS name that aliases an external DNS name, there is little reason to install an operator.

But ExternalName and External Service Discovery Operator solve slightly different problems.

With ExternalName, the resolved addresses are not materialized as Kubernetes EndpointSlice backends. DNS resolution remains part of the consumer path.

External Service Discovery Operator instead performs:

DNS name
   โ†“
resolve
   โ†“
10.140.0.11
10.140.0.12
10.140.0.13
   โ†“
EndpointSlice
   โ†“
selectorless Service

The distinction is subtle but important.

An ExternalName may resolve to several A records, but Kubernetes does not expose those records as individual EndpointSlice backends; resolution and connection behavior remain outside the Service endpoint model.

Requirement ExternalName DiscoveredService
Kubernetes DNS alias to an external hostname Yes Not its main purpose
Concrete external IPs represented in EndpointSlice No Yes
Multiple backends visible as Kubernetes endpoints No Yes
Static external IP targets No Yes
Periodic DNS โ†’ EndpointSlice synchronization No Yes
Additional controller required No Yes

ExternalName is often the simpler and better choice. DiscoveredService is useful when concrete external machines need to participate in the Kubernetes Service endpoint model.


Why would I want concrete EndpointSlices?

Suppose the same Tomcat application runs on three external virtual machines and one stable hostname resolves to all three addresses:

tomcat.internal.example.com
        โ”œโ”€โ”€ 10.140.0.11
        โ”œโ”€โ”€ 10.140.0.12
        โ””โ”€โ”€ 10.140.0.13

Inside Kubernetes, I want consumers to see:

Service/tomcat
       โ†“
EndpointSlice
       โ”œโ”€โ”€ 10.140.0.11:8080
       โ”œโ”€โ”€ 10.140.0.12:8080
       โ””โ”€โ”€ 10.140.0.13:8080

Instead of maintaining those endpoints manually, I can declare:

apiVersion: discovery.k8sready.com/v1alpha1
kind: DiscoveredService
metadata:
  name: tomcat
  namespace: default
spec:
  discovery:
    dns:
      names:
        - tomcat.internal.example.com

  ports:
    - name: http
      port: 8080
      protocol: TCP

The operator resolves the name and creates a selectorless Service plus an EndpointSlice containing the three external addresses. A compatible Kubernetes consumer may then use that endpoint set when distributing connections across the replicas.

The operator does not proxy or load balance traffic. It only discovers, normalizes, and publishes the endpoints.

External Service Discovery Operator architecture showing external workloads discovered through DNS or static IPv4 addresses and projected into Kubernetes Services and EndpointSlices
External Service Discovery Operator turns external DNS names or static addresses into Kubernetes-native Service and EndpointSlice resources.

The original use case

This project came from a very concrete infrastructure problem.

The architecture looked roughly like:

Internet
   โ†“
Traefik
running in Kubernetes
   โ†“
Service
   โ†“
external Tomcat VMs

Tomcat was not running inside Kubernetes.

The original configuration therefore required manually maintaining something similar to:

apiVersion: v1
kind: Service
metadata:
  name: tomcat-erp-external
spec:
  ports:
    - name: http
      port: 8080

---

apiVersion: v1
kind: Endpoints
metadata:
  name: tomcat-erp-external
subsets:
  - addresses:
      - ip: 10.140.0.11
    ports:
      - name: http
        port: 8080

It works.

But now the infrastructure repository contains the VMโ€™s current IP address.

If the VM is recreated and becomes:

10.140.0.11
      โ†“
10.140.0.57

someone must update Git.

The Git repository was storing an implementation detail that DNS already knew.

I wanted instead:

Git
 |
 | stable
 v
tomcat-01.internal.example.com
 |
 | DNS changes
 v
10.140.0.57
 |
 v
EndpointSlice updated automatically

The desired configuration stays stable while discovery handles the runtime address.


DNS discovery

A DiscoveredService can reference one or several DNS names:

apiVersion: discovery.k8sready.com/v1alpha1
kind: DiscoveredService
metadata:
  name: legacy-backend
spec:
  discovery:
    dns:
      names:
        - backend.internal.example.com

  ports:
    - name: http
      port: 8080

A single DNS name may also expose multiple A records:

backend.internal.example.com

10.140.0.11
10.140.0.12
10.140.0.13

The operator normalizes that into:

EndpointSlice

10.140.0.11
10.140.0.12
10.140.0.13

DNS discovery is periodically refreshed.

If the result changes:

before

10.140.0.11
10.140.0.12

after

10.140.0.11
10.140.0.57

the EndpointSlice is reconciled accordingly.

No Git change is required.


Failure should not mean deleting working endpoints

DNS can fail temporarily.

A resolver may be unavailable.

An internal DNS zone may experience an issue.

A hostname may temporarily fail resolution.

In that situation, replacing a healthy EndpointSlice with an empty one would usually make the outage worse.

The operator therefore follows a last-known-good model.

If discovery succeeds:

DNS
 โ†“
10.140.0.11
10.140.0.12

Ready=True
Endpoints=2

and the next discovery fails:

DNS
 โ†“
error

Ready=False
Reason=DiscoveryFailed

the previously materialized EndpointSlice remains untouched.

The operator reports the failure through Kubernetes status without destroying the last successfully discovered state.


Static discovery

DNS is not always available.

Some external systems simply have fixed addresses.

For those cases:

apiVersion: discovery.k8sready.com/v1alpha1
kind: DiscoveredService
metadata:
  name: network-appliance
spec:
  discovery:
    static:
      addresses:
        - 10.20.0.10
        - 10.20.0.11

  ports:
    - name: https
      port: 443

This produces the same Kubernetes abstraction:

DiscoveredService
       โ†“
Service
+
EndpointSlice

but without periodic DNS discovery.

Static discovery is useful for:

  • network appliances
  • stable legacy machines
  • migration environments
  • systems without internal DNS

If stable DNS names exist and IP addresses may change, DNS discovery is usually the more useful model.


Why EndpointSlice instead of Endpoints?

The original manual setup used the legacy Kubernetes Endpoints resource.

The operator deliberately uses:

discovery.k8s.io/v1
EndpointSlice

instead.

EndpointSlice is the modern Kubernetes API used to represent Service backends.

The relationship looks like:

Service
   |
   | kubernetes.io/service-name
   โ†“
EndpointSlice
   |
   โ”œโ”€โ”€ backend 1
   โ”œโ”€โ”€ backend 2
   โ””โ”€โ”€ backend 3

This is particularly useful for selectorless Services.

Normally Kubernetes discovers Service endpoints from Pods matching a selector.

External workloads do not have Kubernetes labels.

The operator therefore becomes the controller responsible for maintaining the EndpointSlice.


Infrastructure-provider independent by design

An interesting property of DNS discovery is that the controller does not need to know where a machine runs.

The same configuration model can represent workloads in:

Google Cloud
AWS
Azure
VMware
OpenStack
bare metal
on-premises infrastructure

The operator does not ask:

Which cloud provider owns this machine?

It asks:

What addresses does this name currently resolve to?

The contract remains:

DNS
 โ†“
addresses
 โ†“
EndpointSlice

There is no cloud SDK in that path.

No cloud credentials are required.

The Kubernetes cluster only needs:

  1. DNS visibility to the configured names
  2. network connectivity to the resulting addresses

The operator does not create that connectivity.


What the operator deliberately does not do

Keeping the project small is intentional.

External Service Discovery Operator is currently not:

  • a load balancer
  • a proxy
  • a service mesh
  • a health checker
  • a cloud inventory system
  • an ingress controller
  • a Gateway API controller
  • a DNS server

It does not create Traefik IngressRoute resources.

It does not proxy application traffic.

It does not inject sidecars.

It does not actively probe whether an external application is healthy.

Its responsibility ends here:

discover
   โ†“
normalize
   โ†“
Service + EndpointSlice

The consumer decides what to do with those endpoints.


When ExternalName is still the better choice

This operator is not intended to replace ExternalName.

If your requirement is simply:

Kubernetes name
      โ†“
external DNS name

use ExternalName.

For example:

apiVersion: v1
kind: Service
metadata:
  name: database
spec:
  type: ExternalName
  externalName: database.internal.example.com

That is simpler.

No controller is required.

No CRD is required.

No additional component needs to run.

External Service Discovery Operator becomes interesting when the requirement changes to:

I want the external targets themselves
represented as Kubernetes endpoints.

A useful rule is:

Need only DNS aliasing?
        โ†“
ExternalName

Need concrete external targets as EndpointSlices?
        โ†“
DiscoveredService

A useful migration bridge

One of the scenarios I find most interesting is gradual Kubernetes adoption.

Real migrations rarely look like:

VM infrastructure
      โ†“
one migration event
      โ†“
everything Kubernetes

They usually look more like:

Kubernetes
โ”œโ”€โ”€ new services
โ”œโ”€โ”€ APIs
โ”œโ”€โ”€ workers
โ””โ”€โ”€ ingress

VMs
โ”œโ”€โ”€ legacy application
โ”œโ”€โ”€ ERP
โ”œโ”€โ”€ application server
โ””โ”€โ”€ systems waiting for migration

Those two environments may coexist for years.

A small discovery controller provides a bridge:

external workloads
       โ†“
DNS
       โ†“
DiscoveredService
       โ†“
Service + EndpointSlice
       โ†“
Kubernetes-native consumers

The external application does not need to know Kubernetes exists.

The Kubernetes consumer does not need to know how the external workload was provisioned.


Architecture

At a high level:

External workloads
        |
        v
DiscoveredService
        |
        v
Discovery Provider
        |
        v
External Service Discovery Operator
        |
        +--> Service
        |
        +--> EndpointSlice
        |
        v
Kubernetes consumers
Traefik / Gateway API / applications / controllers

The discovery provider is deliberately separate from reconciliation. DNS and static discovery both produce a normalized endpoint set; the controller then projects that set into the same native Kubernetes resources.


Installation

Version 0.1.1 is available as an OCI Helm chart.

helm upgrade --install external-service-discovery-operator \
  oci://ghcr.io/pierinho13/charts/external-service-discovery-operator \
  --version 0.1.1 \
  --namespace external-service-discovery-operator-system \
  --create-namespace

The controller image is also published through GitHub Container Registry.


Final thoughts

The interesting part of this project is not resolving DNS.

DNS resolution is easy.

Creating a Service is easy.

Creating an EndpointSlice is easy.

The useful part is connecting those pieces through a continuously reconciled Kubernetes API:

stable desired state
        โ†“
external discovery
        โ†“
changing runtime addresses
        โ†“
Kubernetes-native endpoints

Sometimes ExternalName is exactly what you need.

Sometimes putting an external hostname directly in application configuration is even simpler.

But when Kubernetes consumers need external workloads to behave like native Service endpoints, there is a small gap between DNS and EndpointSlice management.

That is the gap this project is designed to fill.

The project is open source:

https://github.com/pierinho13/external-service-discovery-operator

The current release supports DNS and static discovery, IPv4 EndpointSlices, periodic DNS refresh, deterministic reconciliation, and last-known-good behavior during discovery failures.

If you are running hybrid Kubernetes/VM environments, migrating legacy workloads, or maintaining selectorless Services manually, I would be especially interested in feedback about the use case.