-
-
Notifications
You must be signed in to change notification settings - Fork 4.1k
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
proxy: Unbuffered request optimization #1314
Conversation
Cool, thanks for giving this a go @lhecker! For the test, how about using Or maybe the test could be sized down just a notch? |
@mholt That would still not lead to a reproducible, reliable test (since only OOM errors would be a proof for it to be not working). I just found I'll add such a test okay? 🙂 |
That's... A really neat idea. I've never heard of doing that for a test
like this but it makes sense. Give a whirl! I wonder how the CI environment
will like it...
…On Thu, Dec 29, 2016, 11:30 AM Leonard Hecker ***@***.***> wrote:
@mholt <https://github.com/mholt> That would still not lead to a
reproducible, reliable test (since only OOM errors would be a proof for it
to be not working). I just found debug. SetGCPercent() though with which
we could disable the GC. If we then proxy a relatively large request (e.g.
100MB) and check wether the memory usage after running it is still *below*
the request size we can confirm that it was streaming the request in chunks
with a small buffer (32KB currently) instead of fully loading it into the
memory first.
I'll add such a test okay? 🙂
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#1314 (comment)>, or mute
the thread
<https://github.com/notifications/unsubscribe-auth/ABE5kcNsE4bMdIJKkOIbXKNviCIZEPexks5rM_w-gaJpZM4LXuV_>
.
|
@mholt Found an even easier solution by simply calculating the difference between the I wrote the new test case based on
and if I enable the optimizations it reliably succeeds with For some strange reason the test runs a lot faster if I run it with Edit: This only happens on macOS. Linux is fine. If someone has any idea why that's the case please tell me. 🙂 |
That's interesting -- I don't know why that is! I just tried running the test on my Macbook Pro and I see similar results. |
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.
I have to say, this is a brilliant test you've written. It's simple and seems to be technically effective.
I was just thinking about this more. And I asked the folks on Gophers Slack how other LBs deal with this. One commented that they were not familiar with the idea of load balancers retrying requests automatically. In other words, this is kind of a novel feature in Caddy, probably because of the buffering problem and the fact that retrying POSTs can be sloppy, as POSTs aren't idempotent. (I have mixed feelings on this -- since ideally an upstream shouldn't apply changes when a POST fails!)
Caddy does NOT retry another backend by default, which is the very smart, safe choice. To enable this retry (in other words, to iterate this big for
loop a second time), multiple upstreams need to be provided and try_duration
has to be set to a value > 0. So that's good.
Given that try_duration is 0 and no retries are performed by default, maybe the condition on whether we buffer the whole body should be more specific: multiple upstreams and try_duration > 0. (See the keepRetrying
func.)
What do you think? If you agree, feel free to update the PR and I think I'm almost ready to merge it.
var _ rewindableReader = &unbufferedBody{} | ||
|
||
func (b *unbufferedBody) rewind() error { | ||
panic("cannot rewind unbuffered body") |
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.
Y u no return error? 😓
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.
Ah yes... When I wrote this method it wasn't hidden behind a common interface. Calling rewind()
on it was thus basically a precondition failure, akin to how 501 Not Implemented
is a server error code instead of being a client error like 404. I still kind of think that it's a precondition failure, but since this struct is only used using the rewindableReader
it's surely not the right choice anymore. I'll change it.
More thinking out loud here... The issue that motivated the buffering of proxy bodies is here: #1229 - and the subsequent PR merged the change. From what I can tell, other load balancers like nginx and haproxy will try other upstreams only if they couldn't establish a connection. This makes sense, since it means the request body wouldn't be drained: no was connection was ever established to drain it. Currently, Caddy may try the next upstream for any error returned by the reverseproxy's ServeHTTP method, which (currently) returns its first possible error at the RoundTrip(). It's unclear to me whether RoundTrip() returns an error only on connection problems or potentially after establishing the connection. The net/http docs only talk about whether a "response" was obtained, saying:
I'm not sure whether this is interpreted as "complete response body" or "part of a response"... well anyway, I think the way we do it now is alright, but I'm not sure it's ideal. What I'm getting at is: The server operator should be able to control whether the proxy retries with another upstream. Right now, it is off by default and, if enabled, only happens if RoundTrip() returns an error (presumably a connection error?). This is good. It would be really neat if a "proxy-mode error level" could be defined. Right now, that definition is something like this: "failure to obtain a response" (according to the Go docs), which I guess includes "failure to establish a connection". This is a good default and is what other LBs do. But what if the operator could define "a timeout of 10s" or "a 500 response" as proxy-mode error levels? That way, if an upstream serve was accepting connections but unable to handle them, the operator could tell Caddy to treat a 500 like a connection error: retry the request on a different upstream. (The operator would have to understand this has the potential to retry non-idempotent POST requests and buffer response bodies, but... that's how it goes.) So -- what does this have to do with your PR? It touches on this in a very significant way. Ideally, the only time we should have to buffer the body is if it started being drained, then was stopped, then we had to retry. All other errors -- connection errors, whatever -- shouldn't need it, even if we have multiple upstreams. What do you think? |
The issues begin when not the request to the upstream but it's response fails and you apply a POST request multiple times due to this. E.g. instead of ordering one expensive product you order a hundred and won't notice because the response from the backend is never reaching caddy.
Oh from the docs [1] I thought that a
It returns errors on connection failures, timeouts (as configured on the transport) and on protocol errors (TLS & HTTP). This does not include the response body though because it's not parsed until you actually read from the
Yeah. Here are the nginx docs for this. As you can see it provides a flexibility similar to what you wrote.
I'm not sure if I understand. At the point you try to "retry" without having buffered the body already you cannot retry anymore because you failed to buffer in the first place. So either you start buffering right from the start and be able to retry upstream requests or you don't buffer and can't retry. But I believe there is no other way apart from deciding it before you even try to connect to the first upstream. Even if you say "Well but what if we first see wether we can connect to the first upstream?" we still have to buffer it. The reason is simple: Calls to I don't think we should spend too much time in optimizing the buffering logic for now apart from the 2 obvious cases though (only one upstream or [1]:
|
IMO a "request" should fail or be rejected if any part of the RoundTrip fails, but apparently most backend services don't think about that. (I can see why, it's just not ideal.) Like rolling back a DB transaction if there's an error.
Huh? It doesn't say anything about retrying forever. This is what it says: "try_duration is how long to try selecting available upstream hosts for each request. By default, this retry is disabled (0s)."
That'd be a good change in the meantime, until we figure out exactly how we want to configure retries. I'm not sure yet if it will affect your PR any more than this, so at least make the change and I'll take another look.
By HTTP protocol errors, do you mean if the backend returns a 4xx or 5xx response, an error value is returned? My understanding is that it only happens on connection failures (and maybe timeouts).
Oh, I didn't realize nginx did this. Thanks. Those docs say that nginx can't retry if the body has been read, so I guess they aren't buffering it. I still think buffering isn't all bad in some use cases, as long as the user configures it. So I like where this PR is going.
Right, that's what I mean -- buffer from the start. But we should know ahead of time whether we need to buffer: 1) retries are enabled and 2) there are multiple backends -- and, if we make this configurable, 3) if retries are allowed after a connection has been established. Ideally, only meeting all 3 of those conditions should induce buffering.
Fair enough -- one thing at a time. :) |
No most really don't, especially as soon as you start layering your abstraction on top, where e.g. your DB transaction does not extend until you finished replying to the client. Anyways:
Ah I misread. I thought it said that the client might hang for a long time if retry is disabled. (Take another look at the docs and you'll notice how close my initial understanding and the correct version are. 😅)
Done & Pushed.
No actual protocol errors like e.g. replying with
Yeah. To make it easier to extend I already moved the check to a free-standing assignment. If it get's too complicated we can surely move it to a seperate method in the future. |
I want to have a tus uploading server behind caddy (via a reverse proxy) and this PR might fix my issue: Scenario: Expected behavior (when the request is directly sent to tus listening port) Observed behavior (when caddy acts as a reverse proxy in front of tus): If think that this might fix my issue, I can try to compile this branch on my system and let you know if it works! In this case, it could be used as a test case for this PR: |
(This could also be seen as unrelated to this issue that the caddyserver does not forward incomplete requests) |
I just tried this branch with the tus uploader and resuming works perfectly! I'm looking forward for this PR to be merged 😃 |
Glad to hear it's working for you @oliverpool! I assume incomplete requests work as well then? Because I'm not entirely sure if that's the case… |
I think so. |
@mholt Is everything fine with this PR? And if so: When do you plan on merging it? |
var _ rewindableReader = &unbufferedBody{} | ||
|
||
func (b *unbufferedBody) rewind() error { | ||
return errors.New("cannot rewind unbuffered body") |
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.
Could this just return nil
instead of a error? so you don't a check below.
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.
Oh, you're suggesting to just make this a no-op?
If it's a no-op, maybe the bigger design question is, Why does unbufferedBody
implement rewindableReader
if it is not a rewindable reader?
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.
Hmm, it makes sense.
return http.StatusInternalServerError, errors.New("unable to rewind downstream request body") | ||
// NOTE: We check for requiresBuffering, because | ||
// only a bufferedBody supports rewinding. | ||
if requiresBuffering { |
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.
I think we could clear this checking if unbufferBody
's rewind() just return nil
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.
Actually we only need to rewind the body when the previous one failed.
var _ rewindableReader = &unbufferedBody{} | ||
|
||
func (b *unbufferedBody) rewind() error { | ||
return errors.New("cannot rewind unbuffered body") |
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.
Hmm, it makes sense.
return http.StatusInternalServerError, errors.New("unable to rewind downstream request body") | ||
// NOTE: We check for requiresBuffering, because | ||
// only a bufferedBody supports rewinding. | ||
if requiresBuffering { |
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.
Actually we only need to rewind the body when the previous one failed.
@mholt @tw4452852 I removed the dreaded |
6c7338e
to
7fe93ad
Compare
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.
This PR is looking BEAUTIFUL. Just one more thing related to robustness -- either a test or a code change will do -- and I'm so ready to merge this. Thanks!!
// NOTE: | ||
// Reaching this point implies that keepRetrying() above returned true, | ||
// which in turn implies that body must be a *bufferedBody. | ||
if err := body.(*bufferedBody).rewind(); err != nil { |
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.
Juuuuust to be safe, maybe this code should have one extra check. If the type assertion fails right now, that will lead to a panic (I know, it shouldn't fail, but I suspect we lack tests to enforce this right now). Either a test should be written -- this particular case might be tedious to test for, I'm not sure -- or try this, which won't panic:
if bb, ok := body.(*bufferedBody); ok {
err := bb.rewind()
if err != nil {
// ...
}
}
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.
@mholt Fixed.
Can I squash the last 3 commits into a single one before you merge it to clean up the history? It'd result in a force push though of course.
atomic.AddInt64(&host.Conns, 1) | ||
defer atomic.AddInt64(&host.Conns, -1) | ||
backendErr = proxy.ServeHTTP(w, outreq, downHeaderUpdateFn) | ||
}() |
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.
This won't stop merging, but I just noticed this wrapped in a func(). Why is that? Just curious.
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.
Probably for the defer
?
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.
Just move line 203 to right after line 204... I don't think there's a need to use defer here.
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.
I think it's probably for it to look cleaner (but I'm not the author).
The increment and decrement are now spatially near one another: the rest of the function (only one line here) will then be in between (even in case of an exception or an early return)
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.
It's because proxy.ServeHTTP
can panic under certain conditions, but previously the host.Conns
counter was not decremented properly if it did. That way - in my understanding - the counter was stuck and could sooner or later lead to a broken proxy state.
P.S.: For instance I fixed one possible panic in my previous PR, which could be triggered by opening a WebSocket connection to caddy and causing the upstream to disconnect right after caddy connected to it. That way a 0-length buffer is inserted into the buffer pool and every new WebSocket connection would have randomly caused panics, due to in.CopyBuffer
not accepting 0-length buffers. This in turn would have incremented host.Conns
forever without ever decrementing it.
And if there is one bug causing panics there must be more.
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.
Hm, makes sense. It's a little awkward but we'll roll with it for now. Maybe place a comment explaining why it's in an anonymous func.
time.Sleep(timeout) | ||
atomic.AddInt32(&host.Fails, -1) | ||
}(host, timeout) | ||
}() |
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.
Wait a sec -- why are these parameters removed? It is a bug to start a goroutine in a for
loop and not pass in the values as they are on this iteration of the loop, they might change before the goroutine starts.
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.
For reference: https://github.com/golang/go/wiki/CommonMistakes#using-goroutines-on-loop-iterator-variables
It is also important to note that variables declared within the body of a loop are not shared between iterations, and thus can be used separately in a closure.
So this should actually work fine, but it is a dangerous portion of code (for instance if the for
loop is changed to for host := range upstream.Selector(r) {
)
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.
Sure, I still think it should be reverted.
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.
I'll revert it. I changed it because it was not written in the same style as other parts of the code, which all use implicit binding of closure parameters.
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.
Okay; will await that, I'll feel better if they get passed into the func ;)
If that's the only reason, then I'd like it to change to not use the defer.
…On Sun, Jan 8, 2017, 9:56 AM oliverpool ***@***.***> wrote:
***@***.**** commented on this pull request.
------------------------------
In caddyhttp/proxy/proxy.go <#1314>:
> }
// tell the proxy to serve the request
- atomic.AddInt64(&host.Conns, 1)
- backendErr = proxy.ServeHTTP(w, outreq, downHeaderUpdateFn)
- atomic.AddInt64(&host.Conns, -1)
+ func() {
+ atomic.AddInt64(&host.Conns, 1)
+ defer atomic.AddInt64(&host.Conns, -1)
+ backendErr = proxy.ServeHTTP(w, outreq, downHeaderUpdateFn)
+ }()
I think it's probably for it to look cleaner (but I'm not the author).
The increment and decrement are now spatially near one another: the rest
of the function (only one line here) will then be in between (even in case
of an exception or an early return)
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#1314>, or mute the thread
<https://github.com/notifications/unsubscribe-auth/ABE5kVjWTIAsT6WaEHgCFy52rNSqg5ilks5rQRVCgaJpZM4LXuV_>
.
|
atomic.AddInt64(&host.Conns, 1) | ||
defer atomic.AddInt64(&host.Conns, -1) | ||
backendErr = proxy.ServeHTTP(w, outreq, downHeaderUpdateFn) | ||
}() |
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.
Hm, makes sense. It's a little awkward but we'll roll with it for now. Maybe place a comment explaining why it's in an anonymous func.
time.Sleep(timeout) | ||
atomic.AddInt32(&host.Fails, -1) | ||
}(host, timeout) | ||
}() |
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.
Okay; will await that, I'll feel better if they get passed into the func ;)
I think the root cause for this issue is we have to read full body beforehand to rewind it later when retrying . Here, we just work around it by checking the |
@tw4452852 : something like a TeeReader with a NopCloser (to have a |
@oliverpool Yes, I actually have adopted this way here. 😄 |
That's inherently impossible. You can't decide to buffer the body after you've already consumed it by proxying it to the first upstream host. Some way or the other you have to decide upfront wether you'd like to buffer the body, especially since you have to keep in mind that request payloads don't have an upper bound on their size. Your use in The only optimizations I see is to better abstract this logic away and to optimize |
@lhecker I'm ready to merge this, as soon as the goroutine gets passed the arguments again. 👍 @tw4452852 I appreciate your thinking on this. I'm not sure I understand fully what you have in mind but perhaps you could explain more? Or submit a PR... but yeah, you'll have to decide to buffer before consuming the body. @lhecker Maybe he meant the body will not be buffered unless we know we might retry the request? |
@mholt I pushed the requested changes just now. Can/May I now squash the later commits into the first 3? |
@lhecker Sure! Go ahead and clean up your commits as you please and when ready I'll merge this. @tw4452852 It looks like your concerns have been addressed so I'll assume you're good with my merging this. We can always go back and make little changes if we find anything else. |
If only one upstream is defined we don't need to buffer the body. Instead we directly stream the body to the upstream host, which reduces memory usage as well as latency. Furthermore this enables different kinds of HTTP streaming applications like gRPC for instance.
This test ensures that the optimizations in 8048e9c are actually effective.
149a787
to
601838a
Compare
@mholt I cleaned up the commits. As you can see it's a very compact PR now. 🙂 |
Excellent!! Thanks so much for your patience and persistence @lhecker, and for your help reviewing, @tw4452852 and others. At some point I intend to revisit this (unless somebody else wants to first! I'm busy for a while) to make retries more configurable; i.e. to only buffer if explicitly configured to do so, even with multiple backends. Otherwise, to only retry on connection failures where the request body wasn't drained at all. |
This probably fixes #1310.
Again NOTE: This only works if your
proxy
directive specifies only a single upstream host.