-
Notifications
You must be signed in to change notification settings - Fork 51
fix(worker): give each proxy host its own QUIC socket #1045
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -66,12 +66,32 @@ func createH3RoundTripper() *h3ConnectionCache { | |
| return &h3ConnectionCache{wrappedTransport: h3, clients: make(map[string]*roundTripperWithCount)} | ||
| } | ||
|
|
||
| // newHostTransport opens a UDP socket for one proxy host. | ||
| // | ||
| // A socket per host rather than one shared by all of them, because the source | ||
| // port is what a UDP load balancer hashes on. Sharing one socket means every | ||
| // proxy pod is reached over a single balancer flow, so if that flow is pinned | ||
| // to an instance that has gone away, QUIC to every pod fails at once and keeps | ||
| // failing for as long as traffic continues: the flow never idles out and | ||
| // therefore never re-hashes. Restarting the whole worker was the only way back, | ||
| // which is why replacing function instances has been the standard remedy. | ||
| // | ||
| // Per host, a failed host is discarded together with its socket, and the next | ||
| // attempt arrives from a new source port that the balancer is free to place on | ||
| // a healthy instance. It also isolates hosts from one another. | ||
| func newHostTransport() (*quic.Transport, error) { | ||
| udpConn, err := net.ListenUDP("udp", nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return &quic.Transport{Conn: udpConn}, nil | ||
| } | ||
|
|
||
| // mostly copied from http3.Transport because we need to hijack the http3 client stream | ||
| // and when doing that we can't use the built in http3.Transport.RoundTrip function which caches | ||
| // connections. | ||
| type h3ConnectionCache struct { | ||
| wrappedTransport *http3.Transport | ||
| quicTransport *quic.Transport | ||
| mutex sync.Mutex | ||
| clients map[string]*roundTripperWithCount | ||
| } | ||
|
|
@@ -103,9 +123,17 @@ func (t *h3ConnectionCache) getClient(ctx context.Context, hostname string) (rtc | |
| if !ok { | ||
| ctx, cancel := context.WithCancel(ctx) | ||
| removeOnce := sync.Once{} | ||
| var hostTransport *quic.Transport | ||
| if t.wrappedTransport.Dial == nil { | ||
| hostTransport, err = newHostTransport() | ||
| if err != nil { | ||
| return nil, false, err | ||
| } | ||
| } | ||
| cl = &roundTripperWithCount{ | ||
| dialing: make(chan struct{}), | ||
| cancel: cancel, | ||
| dialing: make(chan struct{}), | ||
| cancel: cancel, | ||
| transport: hostTransport, | ||
| // may be called multiple times if many callers detect failure | ||
| removeFromCache: func() { | ||
| removeOnce.Do(func() { | ||
|
|
@@ -116,7 +144,7 @@ func (t *h3ConnectionCache) getClient(ctx context.Context, hostname string) (rtc | |
| go func() { | ||
| defer close(cl.dialing) | ||
| defer cancel() | ||
| conn, rt, err := t.dial(ctx, hostname) | ||
| conn, rt, err := t.dial(ctx, hostname, cl) | ||
| if err != nil { | ||
| cl.dialErr = err | ||
| return | ||
|
|
@@ -135,6 +163,7 @@ func (t *h3ConnectionCache) getClient(ctx context.Context, hostname string) (rtc | |
| case <-cl.dialing: | ||
| if cl.dialErr != nil { | ||
| delete(t.clients, hostname) | ||
| cl.closeTransport() | ||
|
Comment on lines
164
to
+166
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. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Preserve client identity during cache removal. Line 165 removes the entry by hostname only. A waiter in Pass the expected client to Proposed fix-func (t *h3ConnectionCache) removeClient(hostname string) {
+func (t *h3ConnectionCache) removeClient(hostname string, expected *roundTripperWithCount) {
t.mutex.Lock()
if t.clients == nil {
t.mutex.Unlock()
return
}
cl := t.clients[hostname]
+ if cl != expected {
+ t.mutex.Unlock()
+ return
+ }
delete(t.clients, hostname)
t.mutex.Unlock()Update 🤖 Prompt for AI AgentsSource: Learnings |
||
| return nil, false, cl.dialErr | ||
| } | ||
| select { | ||
|
|
@@ -149,7 +178,7 @@ func (t *h3ConnectionCache) getClient(ctx context.Context, hostname string) (rtc | |
| return cl, isReused, nil | ||
| } | ||
|
|
||
| func (t *h3ConnectionCache) dial(ctx context.Context, hostname string) (*quic.Conn, *http3.ClientConn, error) { | ||
| func (t *h3ConnectionCache) dial(ctx context.Context, hostname string, cl *roundTripperWithCount) (*quic.Conn, *http3.ClientConn, error) { | ||
| var tlsConf *tls.Config | ||
| if t.wrappedTransport.TLSClientConfig == nil { | ||
| tlsConf = &tls.Config{} | ||
|
|
@@ -169,24 +198,20 @@ func (t *h3ConnectionCache) dial(ctx context.Context, hostname string) (*quic.Co | |
|
|
||
| dial := t.wrappedTransport.Dial | ||
| if dial == nil { | ||
| if t.quicTransport == nil { | ||
| udpConn, err := net.ListenUDP("udp", nil) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
| t.quicTransport = &quic.Transport{Conn: udpConn} | ||
| } | ||
| dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) { | ||
| network := "udp" | ||
| udpAddr, err := t.resolveUDPAddr(ctx, network, addr) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| conn, err := t.quicTransport.DialEarly(ctx, udpAddr, tlsCfg, cfg) | ||
| conn, err := cl.transport.DialEarly(ctx, udpAddr, tlsCfg, cfg) | ||
| return conn, err | ||
| } | ||
| } | ||
| conn, err := dial(ctx, hostname, tlsConf, t.wrappedTransport.QUICConfig) | ||
| // Per-dial copy: quic-go's validateConfig writes defaults back into the | ||
| // Config it is handed, so concurrent dials sharing one pointer race on it. | ||
| quicConf := *t.wrappedTransport.QUICConfig | ||
| conn, err := dial(ctx, hostname, tlsConf, &quicConf) | ||
| if err != nil { | ||
| zap.L().Warn("failed to dial quic connection", zap.Error(err), zap.String("hostname", hostname)) | ||
| return nil, nil, err | ||
|
|
@@ -216,11 +241,19 @@ func (t *h3ConnectionCache) resolveUDPAddr(ctx context.Context, network, addr st | |
|
|
||
| func (t *h3ConnectionCache) removeClient(hostname string) { | ||
| t.mutex.Lock() | ||
| defer t.mutex.Unlock() | ||
| if t.clients == nil { | ||
| t.mutex.Unlock() | ||
| return | ||
| } | ||
| cl := t.clients[hostname] | ||
| delete(t.clients, hostname) | ||
| t.mutex.Unlock() | ||
|
|
||
| // Dropping the host has to drop its socket too, otherwise the source port | ||
| // is never released and every retry leaks one. | ||
| if cl != nil { | ||
| cl.closeTransport() | ||
| } | ||
| } | ||
|
|
||
| // Close closes the QUIC connections that this Transport has used. | ||
|
|
@@ -233,24 +266,21 @@ func (t *h3ConnectionCache) Close() error { | |
| } | ||
| } | ||
| t.clients = nil | ||
| if t.quicTransport != nil { | ||
| if err := t.quicTransport.Close(); err != nil { | ||
| return err | ||
| } | ||
| if err := t.quicTransport.Conn.Close(); err != nil { | ||
| return err | ||
| } | ||
| t.quicTransport = nil | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| type roundTripperWithCount struct { | ||
| cancel context.CancelFunc | ||
| dialing chan struct{} // closed as soon as quic.Dial(Early) returned | ||
| dialErr error | ||
| conn *quic.Conn | ||
| clientConn *http3.ClientConn | ||
| cancel context.CancelFunc | ||
| dialing chan struct{} // closed as soon as quic.Dial(Early) returned | ||
| dialErr error | ||
| conn *quic.Conn | ||
| // transport owns this host's UDP socket. Held per host rather than shared | ||
| // so that one unreachable proxy pod cannot take QUIC to every other pod | ||
| // down with it, and so that discarding a failed host also discards the | ||
| // source port it was using. See the note on newHostTransport. | ||
| transport *quic.Transport | ||
| transportOnce sync.Once | ||
| clientConn *http3.ClientConn | ||
|
|
||
| useCount atomic.Int64 | ||
| removeFromCache func() | ||
|
|
@@ -259,10 +289,32 @@ type roundTripperWithCount struct { | |
| func (r *roundTripperWithCount) Close() error { | ||
| r.cancel() | ||
| <-r.dialing | ||
| var connErr error | ||
| if r.conn != nil { | ||
| return r.conn.CloseWithError(0, "") | ||
| connErr = r.conn.CloseWithError(0, "") | ||
| } | ||
| return nil | ||
| // Closing the socket is the point, not just tidiness: a new one is opened | ||
| // from a different source port, which is what lets a load balancer that | ||
| // pinned this flow to a dead target route the next attempt somewhere else. | ||
| r.closeTransport() | ||
| return connErr | ||
| } | ||
|
|
||
| // closeTransport releases this host's UDP socket. Idempotent, because a host is | ||
| // dropped from the cache by several paths and the socket must be released | ||
| // exactly once by whichever gets there first. Leaking it would be worse than | ||
| // the shared socket this replaced: a dial storm would leak one per failure. | ||
| // | ||
| // It deliberately does not wait on dialing. Callers hold the cache lock, and a | ||
| // dial still in flight is one whose host is being discarded anyway. | ||
| func (r *roundTripperWithCount) closeTransport() { | ||
| r.transportOnce.Do(func() { | ||
| if r.transport == nil { | ||
| return | ||
| } | ||
| _ = r.transport.Close() | ||
| _ = r.transport.Conn.Close() | ||
| }) | ||
| } | ||
|
|
||
| // An addrList represents a list of network endpoint addresses. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.