-
Notifications
You must be signed in to change notification settings - Fork 505
[DNM] Respect maxConcurrentStreams for client and server H2 dispatchers #2325
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
[DNM] Respect maxConcurrentStreams for client and server H2 dispatchers #2325
Conversation
Signed-off-by: Zahari Dichev <zaharidichev@gmail.com>
…released Signed-off-by: Zahari Dichev <zaharidichev@gmail.com>
Thanks for digging into this, @zaharidichev. I would expect MAX_CURRENT_STREAMS to be enforced by Netty, actually, so I'm surprised that this needs to be done by us. I created a simple finagle script to test sending 10 concurrent streams and configured Linkerd with a server limit of 5 concurrent streams. It seems like Finagle/Netty respected this option on the client side, at least, since after 5 requests, it started to error with this message: `
It's harder to tell if the server is enforcing this limit since I would need a badly-behaved client to violate the limit in order to test this. |
In terms of backpressure and memory usage, you're totally right that this is much less about the number of open streams and much more about the unread frames. Thankfully, HTTP/2 has a concept of window updates specifically to provide this exact kind of backpressure. When the stream is created, a window size is established which describes the maximum number of bytes the client can send to the server and the client should not send any more until the server indicates that those bytes have been read. This is one of the things that happens when the consumer of a stream calls So if the client is able to send data faster than the server can consume it, this is bug in the window management code. With the default Linkerd settings, there can be a maximum of 1000 streams per connection and the initial window size per stream is 64k. This means that Linkerd should buffer a maximum of about 64M per connection. We should figure out exactly how the old version of strest-grpc client is able to make Linkerd violate this limit. Perhaps reproducing this in miniature would be helpful by setting Linkerd's limits very low and pointing the old version of strest-grpc at it. If everything were working correctly, Linkerd should quickly reach the stream and/or window limits and stop admitting frames. |
@adleong Thanks for the insightful comments. I will setup a little example to illustrate what I mean in a bit more detail. |
@adleong So really this change has to do with enforcing this on the server in case we face a client that does not comply with the protocol. Now a minature example that reproduces that looks like:
If you look closely you will see that not only we set the
Yes and no. The http2 spec defined two mechanisms for that, one is the flow control window per stream and the other is per connection. The stream one does not work in this case because it is not hit. Here its the number of pending streams that matter not the number of frames that are in the stream... On the other side the connection flow control does not work either because of the fact that it is not considered in linkerd at the moment. Namely: object FlowControl {
/**
* Controls whether connection windows are automatically updated.
*
* When enabled, connection-level flow control is effectively
* disabled (i.e. constrained by SETTINGS_MAX_CONCURRENT_STREAMS *
* SETTINGS_INITIAL_WINDOW_SIZE).
*/
case class AutoRefillConnectionWindow(enabled: Boolean)
implicit object AutoRefillConnectionWindow extends Stack.Param[AutoRefillConnectionWindow] {
/**
* By default, the connection window is ignored.
*
* This should be changed on resolution of
* https://github.com/linkerd/linkerd/issues/1044
*/
val default = AutoRefillConnectionWindow(true)
} So here we state that we exercise flow control by imposing the constraint of So really, there are a few things we can do about all this:
I am not really sure I can explain this any better, but if something does not make sense or my understanding is entirely wrong, please call me out :) Its up to you which option (if any) we pick. At least we know where the source of the problem with it tests failing is now |
Thanks for the detailed write-up! I agree with you that the most likely outcome here is that we'll just bump strest-grpc to 0.0.7 and move on, but I'd like to understand exactly what's going on first. So please be patient with me while I try to wrap my head around it 😃 My understanding of the strest-grpc issue is that the client leaves streams in half-closed by never sending an EOS or RST. This means that Linkerd (correctly) maintains state for these streams. You say that the client respects Linkerd's
I'm having a hard time understanding what you mean here. A half-closed stream should always count towards the stream limit. If the client does not send a RST or EOS frame, it should not attempt to create a new stream because this would violate the max concurrent streams value of |
Yes, its interesting to understand. Pently of patience here. And yes I feel you, every time I think about it I get a bit dizzy :) So lets take a look at what happens on both the H2Server and H2Client frame by frame as I think this is where the bones are buried. Frames from the perspective of Server Dispatcher (Linkerd H2 server listening to strest-client)
Frames from the perspective of Client Dispatcher (Linkerd H2 client connecting to strest-server)
Point 4 on the Linkerd H2 client will eventually fail and reset the stream, because the strest server is not accepting these frames. However, our Server dispatcher is at that point ready to accept new streams with new data frames that will accumulate in the queue. So there is a window of time where we have accumulated data frames that need to be sent to the strest server but will not. And in this very windows of time we are ready to accept new streams because our stream in the H2 server is in CLOSED state. The critical part here is that after we transition into CLOSED state on the H2 server stream, we are not guaranteeing that these frames that we ingested have reached successfully their destination and have been released. The part where the protocol is not respected comes from the fact that in normal circumstances the client sends end-of-stream when it's done sending, and the server finishes the RPC with trailer (and also is end-of-stream). So when the client receives and end-of-stream before it sends end-of-stream, it should respond with RST_STREAM. This is what has changed in the go-grpc library and when that is working the way it is we will see an RST_STREAM coming from the strest-client in response to the server issuing EOS first and this will reset the stream on the H2 server side instead of trying to send it to the other side of the fence and hoping that it will get there. |
The client does not open new connections. It uses the same connection for creating a new stream. What I was refering to is that I have configured the strest client to only use one connection and one concurrent stream per connection.
The client needs to send a RST which will drain the stream in this case as it has received an EOS from the server before it has send its EOS, which is unexpected as far as I gather. This RST will cause the frame queue to be drained, which is what needs to happen. Instread it sends an EOS on the last data frame that it sends. Again, the client sends an EOS so we transition into a closed state on our side, but this is not what should happen because these data frames that we have accomulated, are sitting with us much longer thatn they should as already explained.
Technically this is not a GO_AWAY as that would tear down the connection. This should be a stream error not a connection error as defined in the RFC
PS: All this is super confusing and there are multiple small details. I hope I did an ok job explaining it. And I hope things are clearer now |
Ahh, I see! So the client isn't violating the HTTP/2 spec at all, it's just sending a lot of data quickly on short-lived streams. Because each stream is short-lived, stream flow control doesn't help us, and we have not enabled connection flow control. I feel good about this now. It's good to know that this is basically a consequence of #1044 and knowing that may influence how we prioritize #1044 in the future. I don't think it's a good idea to enable connection flow control without a way to configure the window size. I think we've established that this issue is unrelated to |
So yes, we can make it so maxConcurrentStreams is respected on the H2 server in linkerd. I already did that in the first commit. We can also consider putting in end to end backpressure in place like I did in the second commit. The main takeaway here is that H2 is supposed to be e2e backpressured protocol. Linkerd is proxying this traffic but in essence is not really enforing end to end backpressure as its allowing this depletion of resources. In my head the fact that this issue even occurs is pointing to the fact that there is problem that needs to be addressed. Regarding connection level flow control, its also important to keep in mind that, as far as I see it, this is still something that can be violated by the other side and I am not sure we protect against it. So what do you suggest we do:
Dont want to create more problems than I am solving here. |
I think we should go with option number 1. I don't think we should deviate from the spec in terms of how we define an open stream. |
Closed in favor of #2327 |
This commit adds the functionality to respect maxConcurrentStreams for server dispatchers. The reason for this is that H2 settings such as `maxConcurrentStreams` are merely advertisement based. We as a server need to protect against misbehaving clients that do not respect the settings we have advertised. Otherwise we are prone to memory leaks and all sorts of resource depletion problems. This PR is a limited version of #2325 which was a more opinionated version of this work. Signed-off-by: Zahari Dichev <zaharidichev@gmail.com>
This PR addresses two problems
The first commit makes sure that we respect MAX_CONCURRENT_STREAMS settings for the H2 protocol. This has two sides:
Client Side
From the perspective of an H2 client, its pretty easy. We make sure that we process the Http2Settings frame that is received from the remote peer right after the connection initiation is done. This allows us to establish the maximum allowed concurrent streams that we can initiate with the remote. If we issue a request that goes over this advertised by the remote side threshold, we fail the request with a 429 status. Note that this value can be dynamic and the remote peer can at any point send us another settings frame that overrides this threshold. That is accounted for as well.
Server side:
Things here are a bit more under our control. Namely, we take into account the maximumConcurrentStreams setting and track that in our
Netty4ServerDispatcher
. If the remote side tries to send us a headers frame proposing to open another stream that will go over this threshold we respond with a reset frame with aSTREAM_REFUSED
status.The second commit is a bit more radical and attempts to redefine the definition of a concurrent stream from the perspective of an h2 server. Ultimately
MAX_CONCURRENT_STREAMS
has to do with backpressure and the avoidance of resource exhaustion. For that to take effect, we don't only need to limit the amount of streams being created but also change the definition of what is an active stream from the perspective of our H2 server. Until now, the moment we transition our stream state into fully closed, which might happen in the case of sending an EOS frame and receiving a EOS frame, we consider this stream closed and we stop accounting for it. The problem with that is that the number of frames that are in the recv queue of the stream might be arbitrary value. We loose the idea of when these are consumed (drained and released) and hope that this will somehow happen in the future either through successful consumption of the queue or through failure (e.g. service exception).In my mind this is not a perfect strategy as it exposes us to situations where we have streams being created at a really fast pace and not being consumed quickly enough (classic fast producer, slow consumer problem), which leads to depletion of resources for linkerd. This is part of the reason #2320 is happening and why it cannot be resolved by simply setting the
MAX_CONCURRENT_STREAMS
setting.All of that being said, this change makes sure that we complete a stream, when we have consumed and released its last frame (if there are frames at all). Until then this stream is counted towards active streams, although its in
CLOSED
state. This allows for exerting e2e backpressure when acting as an H2 server.I am still trying to come up with a few more tests to make sure this is completely correct, but to me the behaviour sounds logical. Comments are more than welcome :)
Signed-off-by: Zahari Dichev zaharidichev@gmail.com