diff --git a/internal/machine/dns/server.go b/internal/machine/dns/server.go index 5ee509cd..31018ac9 100644 --- a/internal/machine/dns/server.go +++ b/internal/machine/dns/server.go @@ -8,6 +8,7 @@ import ( "math/rand/v2" "net" "net/netip" + "slices" "strconv" "strings" "sync" @@ -40,6 +41,7 @@ type Resolver interface { // to upstream DNS servers. type Server struct { listenAddr netip.Addr + localSubnet netip.Prefix resolver Resolver upstreamServers []netip.AddrPort @@ -53,7 +55,7 @@ type Server struct { // NewServer creates a new DNS server with the given configuration. // If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve // internal DNS queries and not forward any external queries. -func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) { +func NewServer(listenAddr netip.Addr, localSubnet netip.Prefix, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) { if !listenAddr.IsValid() { return nil, fmt.Errorf("invalid listen address: %s", listenAddr) } @@ -87,6 +89,7 @@ func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrP return &Server{ listenAddr: listenAddr, + localSubnet: localSubnet, resolver: resolver, upstreamServers: upstreams, forwardSemaphore: make(chan struct{}, maxConcurrentForwards), @@ -287,7 +290,7 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) { // handleAQuery processes an A query for the internal domain and returns A records for the requested name. // The internal domain suffix is already stripped from the name. An empty list is returned if no records are found. func (s *Server) handleAQuery(name string) []dns.RR { - serviceName := trimInternalDomain(name) + serviceName, mode := extractModeFromDomain(trimInternalDomain(name)) ips := s.resolver.Resolve(serviceName) if len(ips) == 0 { s.log.Debug("Failed to resolve service name.", "service", serviceName) @@ -296,10 +299,28 @@ func (s *Server) handleAQuery(name string) []dns.RR { s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips) if len(ips) > 1 { - // TODO: sort by proximity to the requesting container/machine. For now, just shuffle the IPs. + // Shuffle the IPs to approximate round-robin. + // We want to do this as a baseline for "nearest" mode, as well. rand.Shuffle(len(ips), func(i, j int) { ips[i], ips[j] = ips[j], ips[i] }) + + // Default (mode == "") currently behaves the same as round-robin, + // and nothing additional to do for round-robin (mode == "rr"). + + if mode == "nearest" { + // Sort IPs on local subnet to the top. + slices.SortFunc(ips, func(a, b netip.Addr) int { + aIsLocal := s.localSubnet.Contains(a) + bIsLocal := s.localSubnet.Contains(b) + if aIsLocal && !bIsLocal { + return -1 + } else if bIsLocal && !aIsLocal { + return 1 + } + return 0 + }) + } } // Create A records for each IP. @@ -345,3 +366,13 @@ func trimInternalDomain(name string) string { return strings.TrimSuffix(name, "."+InternalDomain) } + +func extractModeFromDomain(name string) (string, string) { + modes := []string{"nearest", "rr"} + for _, mode := range modes { + if cut, found := strings.CutPrefix(name, mode+"."); found { + return cut, mode + } + } + return name, "" +} diff --git a/internal/machine/machine.go b/internal/machine/machine.go index 8dec8eac..e5d55f62 100644 --- a/internal/machine/machine.go +++ b/internal/machine/machine.go @@ -430,7 +430,12 @@ func (m *Machine) Run(ctx context.Context) error { } dnsResolver := dns.NewClusterResolver(m.store) - dnsServer, err := dns.NewServer(m.IP(), dnsResolver, m.config.DNSUpstreams) + dnsServer, err := dns.NewServer( + m.IP(), + m.state.Network.Subnet, + dnsResolver, + m.config.DNSUpstreams, + ) if err != nil { return fmt.Errorf("create embedded DNS server: %w", err) } diff --git a/test/e2e/dns_test.go b/test/e2e/dns_test.go index 35a321e2..109c112f 100644 --- a/test/e2e/dns_test.go +++ b/test/e2e/dns_test.go @@ -3,6 +3,7 @@ package e2e import ( "context" "fmt" + "regexp" "strings" "testing" "time" @@ -224,4 +225,38 @@ func TestInternalDNS(t *testing.T) { assertNoDNSErrors(t, dnsOutput) }) + + t.Run("nearest mode prioritizes local subnet IPs", func(t *testing.T) { + // Test the "nearest" mode which should sort local subnet IPs first + dnsOutput := runDNSQuery(t, "dns-test-nearest", "nearest."+serviceName+".internal", "/tmp/dns_result_nearest.txt") + t.Logf("Nearest mode DNS query output:\n%s", dnsOutput) + + // Find which machine ran the query + querySvc, err := cli.InspectService(ctx, "dns-test-nearest") + require.NoError(t, err) + require.NotEmpty(t, querySvc.Containers, "Query service should have a container") + queryMachineID := querySvc.Containers[0].MachineID + + // Find the local container IP (on the same machine as the query) + var localIP string + for _, ctr := range svc.Containers { + if ctr.MachineID == queryMachineID { + localIP = ctr.Container.UncloudNetworkIP().String() + break + } + } + require.NotEmpty(t, localIP, "Should find local container IP on query machine %s", queryMachineID) + + // Extract the first IP address from the DNS output using regex + // Pattern matches: "Name: nearest.test-dns-service.internal" followed by "Address: X.X.X.X" + re := regexp.MustCompile(`(?m)Name:\s+[\w\.\-]+\s+Address:\s+([\d\.]+)`) + matches := re.FindStringSubmatch(dnsOutput) + require.Len(t, matches, 2, "Should find Name's Address in DNS output") + firstIP := matches[1] + assert.Equal(t, localIP, firstIP, + "Nearest mode should return local subnet IP first (query machine: %s, local IP: %s, first DNS result: %s)", + queryMachineID, localIP, firstIP) + + assertNoDNSErrors(t, dnsOutput) + }) } diff --git a/website/docs/3-concepts/6-services/1-internal-dns.md b/website/docs/3-concepts/6-services/1-internal-dns.md index 753e99d6..13c1169b 100644 --- a/website/docs/3-concepts/6-services/1-internal-dns.md +++ b/website/docs/3-concepts/6-services/1-internal-dns.md @@ -59,3 +59,75 @@ Address: 10.210.1.3 Name: worker.internal Address: 10.210.1.4 ``` + +## IP Ordering Mode + +Additionally, the IP ordering preference can be specified with a `rr` (round-robin) or `nearest` subdomain prefix. + +### `rr` (round-robin) *current default* +Randomly shuffled order on each lookup. + +``` +$ nslookup rr.worker.internal +Server: 127.0.0.11 +Address: 127.0.0.11#53 + +Name: rr.worker.internal +Address: 10.210.0.3 +Name: rr.worker.internal +Address: 10.210.0.4 +Name: rr.worker.internal +Address: 10.210.1.3 +Name: rr.worker.internal +Address: 10.210.1.4 + +$ nslookup rr.worker.internal +Server: 127.0.0.11 +Address: 127.0.0.11#53 + +Name: rr.worker.internal +Address: 10.210.0.4 +Name: rr.worker.internal +Address: 10.210.1.3 +Name: rr.worker.internal +Address: 10.210.1.4 +Name: rr.worker.internal +Address: 10.210.0.3 +``` + +## Nearest scope +Returns machine-local instances first. + +`machine-a`: +``` +$ nslookup nearest.worker.internal +Server: 127.0.0.11 +Address: 127.0.0.11#53 + +Name: nearest.worker.internal +Address: 10.210.0.3 +Name: nearest.worker.internal +Address: 10.210.0.4 +Name: nearest.worker.internal +Address: 10.210.1.3 +Name: nearest.worker.internal +Address: 10.210.1.4 +``` + +`machine-b`: +``` +$ nslookup nearest.worker.internal +Server: 127.0.0.11 +Address: 127.0.0.11#53 + +Name: nearest.worker.internal +Address: 10.210.1.3 +Name: nearest.worker.internal +Address: 10.210.1.4 +Name: nearest.worker.internal +Address: 10.210.0.3 +Name: nearest.worker.internal +Address: 10.210.0.4 +``` + +The prefixes can be used with service ID and machine-scoped service names, as well (e.g. `nearest.3ecb3a8bbec5fd3f46efb056a934714a.internal` or `rr.0903f0ee483aa97d559eeeaac5e22283.m.worker.internal`).