Skip to content

Commit d05e0b1

Browse files
authored
fix: fallback to http host header for service graph (#997)
1 parent 2e4b049 commit d05e0b1

File tree

2 files changed

+131
-1
lines changed

2 files changed

+131
-1
lines changed

pkg/transform/name_resolver.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ func isValidRDNS(ip string) bool {
144144
ip != "::"
145145
}
146146

147+
// parseK8sFQDN returns the service name and namespace from a Kubernetes FQDN.
148+
func parseK8sFQDN(fqdn string) (string, string) {
149+
fqdn = strings.TrimSuffix(fqdn, ".")
150+
base := trimSuffixIgnoreCase(fqdn, ".svc.cluster.local")
151+
if base == fqdn {
152+
return fqdn, "" // not a K8s FQDN
153+
}
154+
if parts := strings.SplitN(base, ".", 2); len(parts) == 2 {
155+
return parts[0], parts[1]
156+
}
157+
return base, ""
158+
}
159+
147160
func (nr *NameResolver) resolveNames(span *request.Span) {
148161
var hn, pn, ns string
149162

@@ -154,7 +167,10 @@ func (nr *NameResolver) resolveNames(span *request.Span) {
154167
if span.IsClientSpan() {
155168
hn, span.OtherNamespace = nr.resolve(&span.Service, span.Host)
156169
if hn == "" || hn == span.Host {
157-
hn = request.HostFromSchemeHost(span)
170+
hostHeader := request.HostFromSchemeHost(span)
171+
if hostHeader != "" {
172+
hn, span.OtherNamespace = parseK8sFQDN(hostHeader)
173+
}
158174
}
159175
pn, ns = nr.resolve(&span.Service, span.Peer)
160176
if pn == "" || pn == span.Peer {

pkg/transform/name_resolver_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,66 @@ func TestCleanName(t *testing.T) {
198198
assert.Equal(t, "service", nr.cleanName(&s, "127.0.0.1", "service.k8snamespace.svc.cluster.local."))
199199
}
200200

201+
func TestParseK8sFQDN(t *testing.T) {
202+
tests := []struct {
203+
name string
204+
fqdn string
205+
expectedName string
206+
expectedNS string
207+
}{
208+
{
209+
name: "standard K8s FQDN",
210+
fqdn: "bar-server.bar-ns.svc.cluster.local",
211+
expectedName: "bar-server",
212+
expectedNS: "bar-ns",
213+
},
214+
{
215+
name: "with trailing dot",
216+
fqdn: "myservice.mynamespace.svc.cluster.local.",
217+
expectedName: "myservice",
218+
expectedNS: "mynamespace",
219+
},
220+
{
221+
name: "case insensitive suffix",
222+
fqdn: "svc.ns.SVC.CLUSTER.LOCAL",
223+
expectedName: "svc",
224+
expectedNS: "ns",
225+
},
226+
{
227+
name: "just service name (no namespace in FQDN)",
228+
fqdn: "myservice.svc.cluster.local",
229+
expectedName: "myservice",
230+
expectedNS: "",
231+
},
232+
{
233+
name: "not a K8s FQDN - plain hostname",
234+
fqdn: "example.com",
235+
expectedName: "example.com",
236+
expectedNS: "",
237+
},
238+
{
239+
name: "not a K8s FQDN - IP address",
240+
fqdn: "10.0.0.1",
241+
expectedName: "10.0.0.1",
242+
expectedNS: "",
243+
},
244+
{
245+
name: "empty string",
246+
fqdn: "",
247+
expectedName: "",
248+
expectedNS: "",
249+
},
250+
}
251+
252+
for _, tt := range tests {
253+
t.Run(tt.name, func(t *testing.T) {
254+
name, ns := parseK8sFQDN(tt.fqdn)
255+
assert.Equal(t, tt.expectedName, name)
256+
assert.Equal(t, tt.expectedNS, ns)
257+
})
258+
}
259+
}
260+
201261
func TestResolveNodesFromK8s(t *testing.T) {
202262
inf := &fakeInformer{}
203263
db := kube.NewStore(inf, kube.ResourceLabels{}, nil, imetrics.NoopReporter{})
@@ -341,3 +401,57 @@ func TestResolveClientFromHost(t *testing.T) {
341401
assert.Equal(t, "pod2", serverSpan.HostName) // we don't match the IP in k8s, but we have a service name
342402
assert.Equal(t, "something", serverSpan.Service.UID.Namespace)
343403
}
404+
405+
// TestResolveClientFromHost_K8sFQDN demonstrates the following scenario:
406+
// - A client (foo-client) makes an HTTP request to a K8s Service (bar-server)
407+
// - The destination IP seen by eBPF is the Pod IP (after kube-proxy NAT)
408+
// - The Pod IP is not in the K8s informer cache
409+
// - The HTTP Host header contains the K8s Service FQDN
410+
func TestResolveClientFromHost_K8sFQDN(t *testing.T) {
411+
// Create a K8s store with NO matching entries for the destination Pod IP
412+
// This simulates the case where the Pod IP can't be resolved via K8s metadata
413+
inf := &fakeInformer{}
414+
db := kube.NewStore(inf, kube.ResourceLabels{}, nil, imetrics.NoopReporter{})
415+
416+
// Add only the source pod to the store, not the destination
417+
sourcePod := &informer.ObjectMeta{
418+
Name: "foo-client-abc123",
419+
Namespace: "foo-ns",
420+
Kind: "Pod",
421+
Ips: []string{"10.0.1.1"},
422+
Pod: &informer.PodInfo{
423+
Owners: []*informer.Owner{{Kind: "Deployment", Name: "foo-client"}},
424+
},
425+
}
426+
inf.Notify(&informer.Event{Type: informer.EventType_CREATED, Resource: sourcePod})
427+
428+
// The destination Pod IP (10.0.2.5) is NOT in the store
429+
// This simulates the NAT scenario where we see the Pod IP but can't resolve it
430+
nr := NameResolver{
431+
db: db,
432+
cache: expirable.NewLRU[string, string](10, nil, 5*time.Hour),
433+
sources: resolverSources([]string{"k8s"}),
434+
}
435+
436+
// Create a client span representing an HTTP call to a K8s Service
437+
// The HTTP Host header contains the K8s Service FQDN
438+
clientSpan := request.Span{
439+
Type: request.EventTypeHTTPClient,
440+
Peer: "10.0.1.1",
441+
// Destination: Pod IP after NAT (NOT in K8s store)
442+
Host: "10.0.2.5",
443+
// HTTP Host header captured by eBPF, stored as "scheme;host"
444+
Statement: "http;bar-server.bar-ns.svc.cluster.local",
445+
Service: svc.Attrs{
446+
UID: svc.UID{
447+
Name: "foo-client",
448+
Namespace: "foo-ns",
449+
},
450+
},
451+
}
452+
453+
nr.resolveNames(&clientSpan)
454+
455+
assert.Equal(t, "bar-server", clientSpan.HostName)
456+
assert.Equal(t, "bar-ns", clientSpan.OtherNamespace)
457+
}

0 commit comments

Comments
 (0)