feat: round-robin load balancing for HTTP/HTTPS ports#487
Conversation
Review — Round-robin load balancing for HTTP/HTTPS portsThanks for this! Fills a real gap. Verdict: request changes — rebase needed, plus a few gaps worth addressing before merge. 1. Not redundant — real gapThe 2. Bugs / correctness🔴 Critical — PR will not merge into current
|
| File | Gap |
|---|---|
docs/content/docs/providers/docker.md:141–143 |
Port Options table missing loadbalance row |
docs/content/docs/providers/docker-reference.md:93–95 |
Same — Port Options table missing loadbalance |
docs/content/docs/providers/lists.md:128 |
Stale comment "only the first will be used" — now incorrect |
docs/content/docs/providers/lists.md |
No loadBalance YAML key example |
docs/content/docs/advanced/roadmap.md:13 |
Mentions round-robin as future work — should reflect Phase 1 shipped |
CHANGELOG |
No entry |
5. Test gaps
Tests added (good quality):
TestPortConfig_SelectTarget_RoundRobin— 3-target rotation ✓TestPortConfig_SelectTarget_SingleTargetAlwaysFirst✓TestPortConfig_SelectTarget_EmptyTargets✓TestPortConfig_SelectTarget_UnknownStrategyDefaultsToFirst✓TestPortConfig_SelectTarget_ReturnsDeepCopy✓TestPortConfig_SelectTarget_ConcurrentRoundRobin— 16 goroutines × 500 under-race✓TestProxyRewriteRoundRobinDistribution— end-to-end throughproxyRewrite✓
Missing:
| Missing test | Severity |
|---|---|
applyPortOptions with loadbalance=first|roundrobin|invalid — existing TestApplyPortOptions (utils_test.go:205) covers all other options, PR didn't extend it |
High — docker label parsing untested |
Case-insensitivity (loadbalance=ROUNDROBIN) — code does strings.ToLower, no test asserts it |
Medium |
List-provider YAML parsing of loadBalance key — no test in list/ |
Medium |
| TCP/UDP round-robin no-op assertion — PR claims no-op but doesn't test it | Medium |
| Health checker behavior with multiple targets — dead backend still receives traffic | High (ties to the critical bug above) |
| Management token NOT set when round-robin routes to a non-management target — the security fix has no regression test | High — security-critical logic must be guarded |
| Redirect handler unchanged under round-robin | Low |
I verified the PR's tests pass on its own base (go test -race clean, go vet clean) in an isolated worktree.
6. Scope vs. issue #436
Issue defines three phases; maintainer asked for tight v1. PR delivers Phase 1 (HTTP/HTTPS round-robin) and defers Phase 2/3. Scope is appropriate — except the PR doesn't acknowledge that health-checker interaction is a Phase 1 correctness issue, not a Phase 2 feature. At minimum, the description should warn that round-robin routes to backends even when they're known-dead.
TL;DR — blocking items
- Rebase onto current main —
port.gowas split intoport_http.go/port_tcp.go/port_udp.go; the diff will not apply cleanly. - Address health-checker interaction — either skip unhealthy targets in
SelectTarget, or add a loud warning in code + docs. - Add the security regression test for the
isManagementTarget(target, ...)change — token-leak prevention must be guarded. - Add
applyPortOptionstests for the newloadbalance=branch. - Update docs (
docker.md,docker-reference.md,lists.md) — and clarify that the docker label currently has no effect.
Non-blocking: list-provider loadBalance validation/warning, stale-comment cleanup, changelog entry.
Reorder the LoadBalance field added for round-robin load balancing so the PortConfig, port, and PortAPI structs satisfy govet fieldalignment, clearing the lint build failure. Pure field reordering, no behavior or YAML tag changes (applied via fieldalignment -fix).
Follow-up to the maintainer review on the round-robin load balancing PR. - Document that round-robin is not health-aware in this phase: the health checker probes only the first target, so unhealthy backends at index >= 1 still receive traffic. Health-aware selection is deferred (Phase 1.5). - Add a regression test guarding the management-token isolation: under round-robin, a request routed to a non-management backend must not receive the management auth token, while the management target still does. - Extend TestApplyPortOptions with loadbalance=first|roundrobin, uppercase (case-insensitivity), and invalid-defaults-to-first cases. - Validate the list provider's loadBalance value: warn and fall back to "first" on unknown strategies, matching the docker provider. Add tests. - Document the loadbalance option in docker.md / docker-reference.md and note it has no effect on the docker provider yet (single target per port); round-robin works via the list provider. Fix the stale "only the first will be used" comment in lists.md and update the roadmap.
6841021 to
7987078
Compare
|
Thanks for the thorough review. Rebased onto current main and worked through the gaps, pushed as a follow-up commit on top of the rebase. Rebase (blocking #1): done. Health-checker interaction (blocking #2): went with the documented-warning route rather than health-aware selection. Making Security regression test (blocking #3): added applyPortOptions tests (blocking #4): extended Docs (blocking #5): added the Non-blocking: added list-provider validation that warns and falls back to On your redirect question: redirect ports and round-robin proxy ports are mutually exclusive per port (gated on |
Summary
Adds round-robin load balancing across multiple HTTP/HTTPS backends for a single proxied port.
PortConfigalready heldtargets []*url.URLbut the reverse proxy always sent every request to the first target. This change lets a port spread requests across all configured targets whenloadbalance=roundrobinis set.Why this matters
Issue #436 asks for spreading traffic across horizontally scaled backends. Until now each proxy effectively used one backend even when several were configured. This is Phase 1 of that request: round-robin for HTTP/HTTPS, which covers the common case. Least-conn, health-aware draining, and TCP/UDP balancing are left for follow-ups per the maintainer's request to keep the first version small.
Changes
internal/model/port.go: add aLoadBalancefield toPortConfig, strategy constants (first,roundrobin), and aSelectTarget(strategy)method. The round-robin counter lives ontargetStateas anatomic.Uint64so concurrent requests rotate safely. Selection returns a deep-copied URL (same round-trip cloneGetFirstTargetalready used). Default and unknown strategies fall back to first-target, preserving today's behavior.internal/proxymanager/port.go: the HTTP/HTTPS rewrite picks the per-request target viaSelectTarget. The management auth-token check now tests the target actually selected for the request, so the token is never forwarded to a non-management backend under round-robin.internal/targetproviders/docker/: add aloadbalance=first|roundrobinport option (and atsdproxy.loadbalancelabel constant). Unknown values log a warning and default to first.internal/targetproviders/list/: add aloadBalancekey to list-provider ports.TCP and UDP ports continue to use the first target, so setting round-robin on them is a no-op for now.
Testing
go build ./...go test ./internal/model/... ./internal/proxymanager/... ./internal/targetproviders/docker/... ./internal/targetproviders/list/... -raceNew tests cover: 3-target rotation order, single-target always-first, empty-targets returns nil without panic, unknown strategy defaults to first, returned URL is a deep copy, concurrent rotation under
-race, and end-to-end round-robin distribution throughproxyRewrite.Fixes #436
AI was used for assistance.