Case study
Kubernetes application uses a public DNS name for an internal API: how to keep traffic inside the cluster
The client contacted us because their website would occasionally become noticeably slow.
Most of the time it worked normally. CPU and memory utilization looked reasonable, application Pods were healthy, and there was no obvious correlation with deployment activity or database load.
Then, without an apparent change inside the cluster, page generation time would increase.
A few minutes later it could return to normal.
That pattern made the problem difficult to reproduce and easy to misattribute to the application itself.
The first useful correlation: external network RTT
During one of the slow periods, we compared application response time with network measurements from the Kubernetes nodes and Pods.
The interesting part was not latency between Kubernetes Services.
It was latency to external network resources.
Periods of poor website performance correlated with increased RTT outside the cluster.
The exact trigger was not always the same. We observed several conditions that could produce it:
- slower external DNS resolution;
- temporary saturation of the external network channel;
- increased packet loss or RTT upstream;
- routing changes;
- provider maintenance or degradation.
None of these conditions was severe enough to make the site completely unavailable.
They simply added latency.
The question was why external network latency affected requests between components that were running in the same Kubernetes cluster.
Finding the unexpected external dependency
The application used an API URL similar to:
| |
That was perfectly reasonable for browsers accessing the application from the Internet.
The problem was that the server-side application used the same URL.
A request originating inside a Pod therefore followed roughly this path:
flowchart LR
APP["Application Pod"]
DNS["CoreDNS"]
PDNS["Public DNS"]
EDGE["Public network / CDN / Load Balancer"]
GW["Kubernetes Gateway"]
API["API Service"]
APP --> DNS
DNS --> PDNS
PDNS --> EDGE
EDGE --> GW
GW --> API
The application and API could be running on neighboring Kubernetes nodes, but communication between them still depended on external infrastructure.
In effect, an internal request was leaving the cluster and entering it again.
This made application latency dependent on systems that should not have been involved in that request at all.
Why the problem was intermittent
This also explained why the website was difficult to troubleshoot.
When external RTT was normal, the extra network path added little enough latency that nobody noticed.
When external DNS, the uplink, upstream routing, or the provider became slower, every server-side API request inherited that delay.
For example, a page render may require several API calls:
If each request gains additional network latency, the effect can accumulate.
Depending on whether the application performs those requests sequentially or in parallel, a relatively modest increase in RTT can become a visible increase in page generation time.
The infrastructure itself can therefore look healthy:
while users still experience a slow site.
The hidden dependency is:
The obvious solution is not always possible
If application configuration can distinguish between browser-side and server-side API URLs, the cleanest solution is simple.
Use the public hostname for browsers:
| |
and Kubernetes service discovery for server-side communication:
| |
For example:
Then the internal request becomes:
flowchart LR
APP["Application Pod"]
API["API Service"]
APP -->|"cluster-local traffic"| API
No public DNS resolution, external route, CDN, or public load balancer is involved.
But changing the application was not necessarily the best option in this case.
The same API hostname was already part of the application’s configuration, and there was another complication: the hostname did not necessarily map to a single Kubernetes Service.
One hostname can represent several Kubernetes Services
Consider a Gateway API configuration where:
| |
goes to one Service,
| |
goes to another,
and everything else goes to the main API.
For example:
| |
In this configuration, changing:
| |
to:
| |
would be wrong.
The request:
| |
must still pass through the routing layer so that /auth reaches auth-api.
The same applies to routing based on headers, weighted backends, URL rewrites, redirects, or other Gateway policies.
So the requirement became more precise:
Keep
api.example.comunchanged from the application’s point of view, but resolve it to a cluster-internal entry point when the request originates inside Kubernetes.
Split-horizon DNS
This can be implemented with internal DNS resolution.
Externally:
Inside Kubernetes:
The application continues requesting exactly the same URL:
| |
but CoreDNS directs the connection to an internal Kubernetes destination.
flowchart LR
APP["Application Pod"]
DNS["CoreDNS"]
GW["Internal Gateway"]
ROUTE["HTTPRoute"]
AUTH["auth-api"]
FILES["files-api"]
CORE["core-api"]
APP -->|"api.example.com"| DNS
DNS -->|"internal address"| GW
GW --> ROUTE
ROUTE -->|"/auth/*"| AUTH
ROUTE -->|"/files/*"| FILES
ROUTE -->|"/*"| CORE
This has an important property: only DNS resolution changes.
The URL still contains:
| |
so the HTTP request still carries:
| |
and an HTTPS connection still uses api.example.com as the TLS server name.
Gateway routing based on the original hostname therefore continues to work.
CoreDNS rewrite
One way to implement this inside Kubernetes is with the CoreDNS rewrite plugin.
Assume the internal Gateway is reachable through:
| |
A CoreDNS rule can map the public name to it:
The lookup then becomes:
The application does not need to know that this happened.
A simplified CoreDNS configuration could look like:
| |
Now the application still connects to:
| |
but the packets remain inside the cluster.
Keep the Gateway when it provides application routing
For a single backend, CoreDNS could theoretically point directly to a Service.
For more complex applications, keeping an internal Gateway is safer.
The resulting architecture looks like this:
flowchart TD
INTERNET["Internet clients"]
PODS["Application Pods"]
PDNS["Public DNS"]
CDNS["CoreDNS"]
PUBLIC["Public Gateway"]
INTERNAL["Internal Gateway"]
ROUTE["HTTPRoute<br/>api.example.com"]
AUTH["auth-api"]
FILES["files-api"]
CORE["core-api"]
INTERNET --> PDNS
PDNS --> PUBLIC
PODS --> CDNS
CDNS --> INTERNAL
PUBLIC --> ROUTE
INTERNAL --> ROUTE
ROUTE --> AUTH
ROUTE --> FILES
ROUTE --> CORE
Both external and internal clients use the same logical hostname and routing rules.
The difference is only how they reach the Gateway.
HTTPS requires special attention
DNS rewriting does not change ports or terminate TLS.
If the application requests:
| |
the internal endpoint must support HTTPS on the expected port.
This would not work:
DNS cannot convert an HTTPS connection into HTTP.
An internal Gateway is useful here because it can terminate TLS using a certificate valid for:
| |
and then forward requests to internal HTTP Services.
For example:
| |
The client still verifies the certificate against api.example.com, so the certificate presented by the internal Gateway must be valid for that hostname.
Test the route before changing DNS
DNS does not need to be changed to test the architecture.
Suppose the internal Gateway Service has ClusterIP:
| |
From the application Pod:
curl --resolve forces the connection to the specified IP while preserving the hostname.
That means the test still uses:
| |
and TLS still sees:
| |
This makes it a useful test for exactly this scenario.
Test all important routes:
Only after those requests reach the expected backends should the DNS override be introduced.
Verify from the actual application Pod
After applying the CoreDNS change:
The returned address should now belong to the internal path rather than the public load balancer or CDN.
Then test the real URL:
There should be no need for --resolve anymore.
The Gateway route should also be checked:
| |
and:
For troubleshooting, it is worth checking the complete path rather than stopping after successful DNS resolution:
A successful lookup alone does not prove that the request reaches the intended backend.
hostAliases can be useful for proving the hypothesis
Before changing CoreDNS cluster-wide, a temporary Pod-level override can be useful:
This is a reasonable diagnostic step.
If application latency stops following external network RTT after this change, the hypothesis is strongly confirmed.
But hostAliases is usually not a good permanent implementation.
The IP address is hardcoded. If the Gateway Service is recreated with another ClusterIP, the configuration becomes stale.
CoreDNS can instead map the public hostname to the Kubernetes Service DNS name:
That preserves Kubernetes service discovery.
ExternalName solves the opposite problem
An ExternalName Service can make a Kubernetes Service name point to an external hostname:
This gives:
Our requirement is the reverse:
so ExternalName does not solve this case.
Do not accidentally remove functionality provided by the public edge
One more check is necessary before bypassing the public path.
The external endpoint may provide functionality such as:
If internal requests depend on any of those, bypassing the edge can change application behavior.
The right objective is not:
bypass as much infrastructure as possible.
It is:
remove infrastructure that should not be in the internal request path while preserving infrastructure that is part of application behavior.
In this case, keeping the Gateway routing layer while removing public DNS and the external network path gave us that separation.
Before and after
The original request path was:
flowchart LR
APP["Application Pod"]
CDNS["CoreDNS"]
PDNS["Public DNS"]
NET["External network"]
EDGE["CDN / Public LB"]
GW["Gateway"]
ROUTE["HTTPRoute"]
API["API"]
APP --> CDNS
CDNS --> PDNS
PDNS --> NET
NET --> EDGE
EDGE --> GW
GW --> ROUTE
ROUTE --> API
Website performance therefore depended partly on external network conditions.
After the change:
flowchart LR
APP["Application Pod"]
CDNS["CoreDNS"]
GW["Internal Gateway"]
ROUTE["HTTPRoute"]
API["API"]
APP -->|"api.example.com"| CDNS
CDNS -->|"cluster-local"| GW
GW --> ROUTE
ROUTE --> API
The application URL remained unchanged:
| |
The routing behavior remained unchanged.
But server-side communication no longer depended on external DNS resolution, upstream network RTT, public load-balancer reachability, or provider routing conditions.
The broader troubleshooting lesson
The initial symptom looked like an application performance problem:
The website is usually fast, but occasionally becomes slow for no obvious reason.
CPU, memory, database performance, and Kubernetes health were not enough to explain it.
The useful clue appeared only after comparing application latency with external network RTT.
That exposed an architectural dependency which was easy to miss: two applications running inside the same Kubernetes cluster were communicating through a public endpoint.
Public hostnames inside application configuration are not necessarily a problem. They become a problem when server-side traffic unnecessarily inherits the availability and latency characteristics of an external network path.
When troubleshooting intermittent application latency in Kubernetes, it is therefore worth checking not only which service a request reaches, but also how it gets there.