Skip to content

cli: implement round-robin DNS IP rotation This change introduces det…#454

Open
maniche1024 wants to merge 9 commits intominio:masterfrom
maniche1024:distributed-traffic
Open

cli: implement round-robin DNS IP rotation This change introduces det…#454
maniche1024 wants to merge 9 commits intominio:masterfrom
maniche1024:distributed-traffic

Conversation

@maniche1024
Copy link
Copy Markdown

@maniche1024 maniche1024 commented Feb 3, 2026

Summary

This PR introduces deterministic, transport-level IP rotation to ensure even load distribution across multi-A record S3 endpoints. It specifically addresses environments where the storage backend (e.g., Hitachi Virtual Storage Platform One) or its ingress layer (e.g., Istio Gateways) experiences connection hotspots due to the client pinning to a single resolved IP.

The Problem: Connection Hotspots & SNI Mismatches

When benchmarking S3-compatible storage sitting behind multiple gateways, Warp can experience unequal traffic distribution. While Warp has a --resolve-host flag, using it often leads to two issues in modern architectures:

  • Protocol Violations: In environments like Hitachi VSP One, using a raw IP in the request often causes the IP to be misinterpreted as the bucket/resource name, resulting in HTTP 400 Bad Request.
  • TLS/SNI Failures: Forcing a connection to a specific backend IP usually breaks TLS handshakes because the SNI (Server Name Indication) is either missing or doesn't match the certificate's SAN (Subject Alternative Name).

Observed behavior (Before): In a test against 5 gateways, traffic was pinned to only 3, with a severe skew:

Connections Gateway IP Status
1 172.18.43.xxx Pinning
431 172.18.43.xxx Overloaded
168 172.18.43.xxx Overloaded
0 172.18.43.xxx Idle
0 172.18.43.xxx Idle

Resolution: Changes' Summary

  • cli/client.go — Introduces a hostPair struct pairing each resolved IP with its original hostname. parseHostPairs() produces one pair per IP; getClient() uses originalHost as the S3 endpoint (for correct request signing and virtual-host bucket routing) and the resolved IP only for dialing.

  • cli/client_transport.go — Adds a withResolveHost() transport option that rewrites dial addresses from the logical hostname to the resolved IP, with correct proxy bypass handling.

  • cli/client_tls.go and cli/client_ktls.go — SNI (ServerName) is now derived from originalHost (the hostname), not the resolved IP. Both standard TLS and Kernel TLS paths use withResolveHost() in resolve-host mode.

  • cli/client_default.go — Plain HTTP transport also uses withResolveHost() when a resolved host is provided.

  • cli/flags.go — Removed stale warning ("This can break SSL certificates, use --insecure if so") since TLS now works correctly without --insecure

Evidence: Test Results

Post-implementation tests demonstrate perfectly balanced connection distribution across all 5 gateways without any HTTP 400 or TLS handshake errors.

Observed behavior (After):

Connections Gateway IP Status
120 172.18.43.xxx Balanced
120 172.18.43.xxx Balanced
120 172.18.43.xxx Balanced
120 172.18.43.xxx Balanced
120 172.18.43.xxx Balanced

Performance Impact & Verification

Comparative analysis between the stock Warp binary and this PR demonstrates that deterministic IP rotation significantly improves performance by preventing gateway saturation.

Test Environment: 600 concurrency, 1KB PUT operations, 5-gateway backend.

Metric Stock Warp (Unbalanced) This PR (Balanced) Improvement
Avg Throughput 1820.33 obj/s 2118.52 obj/s +16.4%

@maniche1024
Copy link
Copy Markdown
Author

Hi, @klauspost @harshavardhana , I've implemented transport-level DNS rotation and SNI preservation to fix gateway hotspots. I've included benchmark results in the description showing a ~16% throughput improvement. Ready for review when you have a moment, thanks in advance!

@klauspost
Copy link
Copy Markdown
Collaborator

I am not a particular fan. Mainly because round-robin is quite ineffective if there is even the slightest imbalance in the servers. Then you see throughput significantly below your capacity.

I would much rather see that --resolve-host got changed so the issues you list get fixed. AFAICT this would be fixed by keeping the host names internally, but resolves to the same IP. It should be pretty trivial to replace the URL:

Add to pkg/bench...

type Client struct {
	*minio.Client
	Host *url.URL
}

// EndpointURL returns the endpoint URL.
func (c *Client) EndpointURL() *url.URL {
	if c.Host != nil {
		return c.Host
	}
	return c.Client.EndpointURL()
}

Does that make sense?

@maniche1024
Copy link
Copy Markdown
Author

Hi Klaus, thanks for the guidance.

Yeah it does make sense but I’ve located the client initialization logic in cli/client.go (I believe you might have been referring to this rather than pkg/bench, as that's where minio.New is called). I am currently refactoring getClient and newClient to use the Client wrapper you suggested so that EndpointURL() returns the original hostname for SNI and Host headers, even when connecting to the resolved IPs.

I'll perform some validation tests and update the PR once verified. Does this sound like the right direction?

@maniche1024
Copy link
Copy Markdown
Author

Think I fully understood the context of the changes you were suggesting when started working on it. It definitely required more work than I had initially anticipated but thankfully was able to figure out eventually. Providing a summary of changes below:

  • Modified the transport layer to "pin" TCP connections to specific backend IPs while preserving the domain name in the S3 Host header.
  • Updated the dialers in client_tls.go and client_ktls.go to provide the correct SNI during the handshake, ensuring certificate validation passes even when connecting directly to an IP.
  • Synchronized pkg/bench logic to ensure all operations (Put, Multipart, etc.) leverage the updated client initialization.

Ran several tests to ensure:

  • No change in behavior when not using --resolve-host flag, i.e. could see heavily skewed traffic distribution with a couple of nodes basically sitting idle
  • When using --resolve-host flag, I can see perfect distribution of traffic to all backend gateways. Also, not running into cert issues when using --tls with --resolve-host

Let me know your thoughts on this or if you need more information

@klauspost
Copy link
Copy Markdown
Collaborator

@harshavardhana You are probably the best to evaluate this. Sounds reasonable to me.

@maniche1024
Copy link
Copy Markdown
Author

@klauspost @harshavardhana just a quick bump on the Warp changes PR when you have a moment. I'm keen to get this merged so I can start on the next phase. Let me know if there's anything I can clarify!

Copy link
Copy Markdown
Collaborator

@klauspost klauspost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@maniche1024
Copy link
Copy Markdown
Author

@klauspost thanks for the approval! But looks like I don't have merge privileges, so either would need that or please merge the PR.

@klauspost
Copy link
Copy Markdown
Collaborator

@maniche1024 I would want @harshavardhana to also take a look. He can merge if he approves.

Comment thread cli/client_transport.go Outdated
Comment thread cli/client.go Outdated
Comment thread cli/client.go Outdated
Comment thread cli/client_transport.go Outdated
Comment thread cli/client_ktls.go
Comment thread cli/flags.go Outdated
Comment thread pkg/bench/ops.go Outdated
@maniche1024
Copy link
Copy Markdown
Author

Hi @ramondeklein Thanks for the feedback. I’ve addressed all the review comments by removing the Client wrapper and updating the IP-pinning logic in the relevant transport layer files. I've also updated the Dialer to be proxy-aware, it now only performs IP pinning when no proxy is configured. I’ve verified the fix with my previous test suite, including cases for hosts supplied with custom ports. Everything is behaving as expected, and the results remain consistent. Hope things look good this time!

Copy link
Copy Markdown
Contributor

@ramondeklein ramondeklein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the proper way to deal with IP pinning is to only use a custom dialer and SNI when IP pinning is actually being used. It has too many side effects. It's probably fine not to support HTTP(S) proxies when using IP pinning, but it should work without it.

Comment thread cli/client_transport.go Outdated
Comment thread cli/client.go Outdated
Comment thread cli/flags.go Outdated
Comment thread cli/client_ktls.go Outdated
Comment thread cli/client_tls.go
@maniche1024
Copy link
Copy Markdown
Author

@ramondeklein I’ve pushed updates addressing all your recent comments, including the proxy-safety logic and the kTLS dialer optimization. The logic is now unified across client_tls, client_ktls, and client_transport. Ready for another look!

@ramondeklein
Copy link
Copy Markdown
Contributor

ramondeklein commented Feb 16, 2026

Critical: pinTarget is always empty — SNI/dialer code is dead

The core mechanism doesn't work. In getClient() (client.go:153-174):

u, _ := url.Parse(host)            // e.g. host="172.18.43.100:9000"
if u == nil || u.Host == "" {       // url.Parse without scheme → Host=""
    u, _ = url.Parse(fmt.Sprintf("%s://%s", scheme, host))  // → Host="172.18.43.100:9000"
}
endpointHost := u.Host              // "172.18.43.100:9000"
pinTarget := ""
if host != endpointHost {           // "172.18.43.100:9000" == "172.18.43.100:9000" → ALWAYS equal
    pinTarget = host
}

For the standard host:port format that parseHosts() returns (both resolved IPs and hostnames), host always equals endpointHost after parsing. So pinTarget is always "", and all the SNI and custom DialContext code never activates.

SNI logic is inverted

In client_tls.go:30-39 and client_ktls.go:33-43:

sni = h                      // extract host from endpoint
if net.ParseIP(sni) == nil { // if it's NOT an IP...
    sni = ""                 // ...clear it
}

This sets ServerName to an IP address. But the whole point of SNI preservation is the opposite: when dialing an IP, you need ServerName set to the original hostname so TLS certificate validation works. The comment "Set only if pinning to an IP" confirms the intent, but the result is that ServerName would be an IP string, which doesn't help with certificate matching.

Duplicate parseHosts() in getCommon()

flags.go:351 calls parseHosts(ctx.String("host"), false) (no resolution), while newClient() inside bench.Common.Client calls parseHosts(rawHost, ctx.Bool("resolve-host")). When --resolve-host is set, the transport in bench.Common.Transport gets the unresolved hostname, while actual client connections use resolved IPs — these are decoupled.

PR description is stale

Mentions sync.Map, atomic counter, DNS cache — none exist in the current code. The description should reflect the actual implementation after the review iterations.

Minor

  • getClient signature reformatted to multi-line for no reason (client.go:149-152).
  • newAdminClient passes hosts[0] as endpoint (client.go:351), but the admin client also uses hosts[0] as its target, so the custom dialer's targetHost != host check makes it a no-op there too.

Suggested direction

For --resolve-host to work properly with TLS, the original hostname needs to be preserved and threaded through to getClient(). Something like:

  1. parseHosts() returns (resolvedHost, originalHostname) pairs when doing DNS resolution
  2. getClient() creates the minio client with the original hostname (for signing + SNI) but configures the transport to dial the resolved IP
  3. The SNI logic should set ServerName to the hostname (not the IP)

…erministic IP-level load distribution at the

transport layer. Previously, Warp relied on the OS or a one-time
resolution, which could lead to uneven traffic distribution when using a
single hostname sitting in front of multiple gateways/IPs Key improvements:
- Added a thread-safe DNS cache (sync.Map) and atomic counter to rotate
  between IPs for every new connection.
- Fixed TLS SNI verification: when dialing a resolved IP, the original
  hostname is now explicitly set in TLSClientConfig.ServerName to
  prevent certificate hostname mismatches.
- Applied rotation logic to both standard TLS and kTLS transport paths.
- Optimized performance by reducing redundant DNS lookups during
  high-concurrency benchmarks.
@maniche1024 maniche1024 force-pushed the distributed-traffic branch from fa5e768 to a1137f2 Compare April 7, 2026 12:53
@maniche1024
Copy link
Copy Markdown
Author

maniche1024 commented Apr 7, 2026

Hi @ramondeklein, thanks for the thorough review again, I was out for about a month and half due to personal reasons hence could not address your review comments. Did it now after coming back to work, here's a summary:

  1. SNI set to IP: ServerName in tls.Config is now extracted from originalHost (the hostname) in both clientTransportTLS and clientTransportKTLS, not from the resolved IP.
  2. S3 signing used IP: getClient() now receives both the resolved IP and the original hostname separately via hostPair. The originalHost is passed as the endpoint to minio.New(), so all request signing and Host headers use the correct hostname. The resolved IP is used only for dialing.
  3. proxy bypass: withResolveHost() checks whether the target address already matches the resolved IP before rewriting, ensuring connections routed through a proxy are not affected.
  4. stale PR description: Updated — removed all references to the atomic counter and "Hostname-Aware IP Rotator" approach. The implementation now uses the transportOption pattern consistent with the rest of the codebase, including the per-NIC binding introduced in bench: add per-NIC source IP binding for multi-NIC benchmarks #465.
  5. stale --insecure warning: Removed from the --resolve-host usage text since TLS works correctly without it.

I have also tested my changes thoroughly and everything looks good and I see expected results.

@maniche1024
Copy link
Copy Markdown
Author

Hi @ramondeklein — just wanted to follow up on this PR. I've addressed all the review comments you raised (SNI fix, endpoint hostname for signing, proxy bypass, and the stale description), and have pushed the updated changes. Would really appreciate another look when you get a chance. Happy to answer any questions or make further adjustments. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants