-
Notifications
You must be signed in to change notification settings - Fork 66
feat(gateway): load balancing #370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
90e25b4
6210b36
1d66d56
f5b073a
5e9ed35
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,6 +57,16 @@ const ( | |
|
|
||
| const heartbeatInterval = 3 * time.Minute | ||
|
|
||
| // Reported over the same direct HTTP client as the heartbeat. The relay connection cannot carry this: | ||
| // the gateway only accepts channels on it, so it runs platform-to-gateway and has no reverse path. | ||
| // Kept off the heartbeat itself because that handler probes back through the relay and writes to the | ||
| // platform's database, which is far too costly at the cadence pool selection needs. | ||
| const metricsReportInterval = 10 * time.Second | ||
|
|
||
| // Must stay below the interval so a stalled endpoint cannot hold the reporting loop past the next | ||
| // tick. Without a bound the loop would also stop noticing cancellation and shutdown would hang. | ||
| const metricsReportTimeout = 5 * time.Second | ||
|
|
||
| const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1" | ||
| const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2" | ||
| const PAM_INFO_OID = "1.3.6.1.4.1.12345.100.3" | ||
|
|
@@ -144,6 +154,11 @@ type Gateway struct { | |
| mongoProxies map[string]*mongoProxyEntry | ||
| mongoProxiesMu sync.Mutex | ||
| pkcs11Module Pkcs11Module | ||
|
|
||
| // Every channel reaching this gateway is accepted in handleIncomingChannel, whoever opened it, | ||
| // so counting there is the only place that cannot be bypassed by a new caller. The platform uses | ||
| // it to pick the least loaded member of a gateway pool. | ||
| activeChannels atomic.Int64 | ||
| } | ||
|
|
||
| // mongoProxyEntry holds a session-level MongoDB proxy with a ready signal. | ||
|
|
@@ -384,6 +399,42 @@ func (g *Gateway) reapIdleSessions() { | |
| } | ||
| } | ||
|
|
||
| // reportLoad publishes the gateway's active channel count so the platform can route new work to the | ||
| // least loaded member of a pool. Failures are not surfaced: a missed report only costs accuracy, and | ||
| // the platform falls back to its own view when a gateway stops reporting. | ||
| func (g *Gateway) sendMetricsReport(ctx context.Context, count int64) error { | ||
| reqCtx, cancel := context.WithTimeout(ctx, metricsReportTimeout) | ||
| defer cancel() | ||
| return api.CallGatewayMetricsReportV2(reqCtx, g.httpClient, api.GatewayMetricsReportRequest{ActiveChannels: count}) | ||
| } | ||
|
|
||
| func (g *Gateway) reportMetrics(ctx context.Context) { | ||
| go func() { | ||
| ticker := time.NewTicker(metricsReportInterval) | ||
| defer ticker.Stop() | ||
|
|
||
| var last int64 = -1 | ||
| for { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return | ||
| case <-ticker.C: | ||
| count := g.activeChannels.Load() | ||
| // Still republish an unchanged count so the platform can tell a quiet gateway from | ||
| // one that has stopped reporting. | ||
|
bernie-g marked this conversation as resolved.
|
||
| if err := g.sendMetricsReport(ctx, count); err != nil { | ||
| log.Debug().Msgf("Load report failed: %v", err) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An older self hosted platform has no metrics endpoint, so this retries every 10s forever. Maybe we should have some back-off mechanism that stops the report after X failures? |
||
| continue | ||
| } | ||
| if count != last { | ||
| log.Debug().Msgf("Reported %d active channels", count) | ||
| last = count | ||
| } | ||
| } | ||
| } | ||
| }() | ||
| } | ||
|
|
||
| func (g *Gateway) registerHeartBeat(ctx context.Context, errCh chan error) { | ||
| sendHeartbeat := func() error { | ||
| capabilities := map[string]any{} | ||
|
|
@@ -575,6 +626,7 @@ func (g *Gateway) startHeartbeatOnce(ctx context.Context, errCh chan error) { | |
| defer g.heartbeatMu.Unlock() | ||
| if !g.heartbeatStarted { | ||
| g.registerHeartBeat(ctx, errCh) | ||
| g.reportMetrics(ctx) | ||
| g.heartbeatStarted = true | ||
| } | ||
| } | ||
|
|
@@ -884,6 +936,9 @@ func (g *Gateway) handleIncomingChannel(newChannel ssh.NewChannel) { | |
| } | ||
| defer channel.Close() | ||
|
|
||
| g.activeChannels.Add(1) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude: This counter only goes down via the defer, so a leaked goroutine inflates it permanently and the gateway stops getting work. Could we reset it to 0 on each new relay connection? |
||
| defer g.activeChannels.Add(-1) | ||
|
|
||
| go ssh.DiscardRequests(requests) | ||
|
|
||
| // Create mTLS server configuration | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment mentions "reportLoad" but this is the "sendMetricsReport" function
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also this comment may be for "reportMetrics" instead of "sendMetricsReport"? Not 100% sure
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also maybe we should rename "reportMetrics" to "startMetricsReport" to be more clear