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.
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:
- DNS visibility to the configured names
- 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.
Comments