Open marvin-roesch opened 3 months ago
/move-kind bug /kind support /triage needs-information
I understand why your reproduce example choice is to have pods with nginx configured to return 404 on location /.
But that can not be considered a real-world use-case as real-world workloads don't have all the pods configured for returning 404.
If you want you can change the test to a real-world use case where first the pod or pods are returning 200. Then introduce a event for 4XX or 5XX. And so on and so forth. But unless you can post the ta like kubectl describe
outputs and kubectl logs
outputs and curl
outputs and responses etc etc etc, others will have to make efforts over and beyond the normal to even triage this issue.
/remove-kind bug
@longwuyuan While I agree the reproduction example is a bit contrived and not particularly reflective of any real world use case, it is the easiest way to reliably reproduce this issue without overly complicating the test case. The sporadic nature of this issue is why I have opted for such a simplistic approach for reproducing it. If the backend service is acting reliably at all (preventing proxy_next_upstream
from having to do anything), the probability of encountering this issue goes down drastically. It doesn't particularly matter that in the end the response still is 404, the access logs I have included clearly demonstrate the issue.
To maybe point you more directly to where the issue lies as can be seen from my reproduction example, note this access log line that one of my curls produced in the ingress controller nginx:
::1 - - [23/Aug/2024:08:53:21 +0000] "GET / HTTP/1.1" 404 1 "-" "curl/8.9.0" 70 0.001 [default-next-upstream-repro-http] [] 10.1.254.93:80, 10.1.254.93:80, 10.1.254.93:80 0, 0, 1 0.000, 0.000, 0.000 404, 404, 404 b753b1828cc200d3c95d6ecbc6ba80e6
As you can see, the $upstream_addr
value is 10.1.254.93:80, 10.1.254.93:80, 10.1.254.93:80
, so the same endpoint gets used thrice over despite the proxy_next_upstream
config. I have omitted the surrounding access log lines that look similar (just with a few different $upstream_addr
values) since they just add noise to the fairly random nature of this issue.
I have amended the command for getting the logs from the ingress controller and will happily provide more information, but I think the example I have provided is the minimal reproducible one. The problem happens entirely in the ingress nginx and for any error case that proxy_next_upstream
can handle, a 404 is just much simpler to produce than a connection error.
ok. I am on a learning curve here so please help out with some questions.
Replicas is 2 and both are causing a lookup for next upstream. Do I have to reproduce on my own to figure out what happens when there is at least one replica that is doing 200 instead of 404 ? Does that one not get picked ?
If any of the upstreams that get picked return a non-error response, nginx behaves as expected and ends the retry chain there. Since for any one request, another attempt is only performed in the case there is an error according to the proxy_next_upstream
config, the problem lies solely with how the next upstream gets picked by the Lua implementation of load balancing as shipped with and used by the ingress controller by default (https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/lua/balancer.lua being the entry point for this).
The default template configures proxy_next_upstream
for upto 3 attempts, which is where the 3 occurrences in $upstream_addr
in the access logs come from. Leaving everything else also at the default (i.e. using round-robin load balancing), manually firing an occasional request is not going to show the issue of the same upstream being used twice in a row, because the global load balancer state isn't affected by multiple concurrent requests. That's why my repro has a command that performs several requests in parallel.
In Nginx, fail_timeout and max_fails remove failed backend for a certain period of time, but the balancer does not have this capability.
proxy_next_upstream_tries 3;
upstream test {
server 127.0.0.1:8080 fail_timeout=30s max_fails=1; # Server A
server 127.0.0.1:8081 fail_timeout=30s max_fails=1; # Server B
}
If A fails once, it will be removed for 30 seconds
While experimenting with the upstream-hash-by
annotation and accordingly the chash load balancer, I have noticed that the issue is even worse there. If you have proxy_next_upstream
configured (as is the default), and an ingress utilizing that load balancer, a different upstream will never be attempted for a single request because the parameters always hash to the same upstream.
This can easily be verified by adding e.g. the nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri"
annotation to my example upstream from the initial issue description. Even for single requests, you can then observe that the $upstream_addr
will list the same upstream multiple times over.
The OpenResty load balancers have support for selecting a "next" upstream (although the round-robin implementation is lackluster, as it is the same as the regular balancing logic, so it's subject to the same concurrency problem), e.g. with this implementation for the consistent hashing balancer: https://github.com/openresty/lua-resty-balancer/blob/1cd4363c0a239afe4765ec607dcfbbb4e5900eea/lib/resty/chash.lua#L319-L324
This has no effect for ingress-nginx, as only the find
method will ever be used when load balancing - retry or not - e.g.
https://github.com/kubernetes/ingress-nginx/blob/1c2aecbf02898845b45cee3d0b493347b440995e/rootfs/etc/nginx/lua/balancer/chash.lua#L29-L32
I think what needs to happen at the very least is that the usage of these load balancers in ingress-nginx's Lua code retain the index of the last used upstream and then use the next
method rather than find
when a retry is being attempted. Otherwise, the default proxy_next_upstream
config is just confusing and misleading.
@marvin-roesch There are no resources like developer time available to allocate for research and triaging. Particularly so for features that are further away from the core Ingress-API specs.
Secondly, you have only 2 pods in your test, both pods are configured to return 404 for ALL requests, and you are expecting the controller to route to next_upstream and get a 200. If I understand your test correctly, I can't guess how/where/why the controller should route to a new next_upstream(ipaddress of pod) when there is no 3rd pod that is healthy or capable of responding with 200.
Again, @longwuyuan, the fact that all pods return 404 should not matter. There might well be a real-world case where a system is in a bad state and thus all upstreams return an error - that is fine for that case and "expected". The problem is that ingress-nginx does not attempt any other upstream at all, it just tries the same one over and over again (in the case of consistent hashing) or whatever the round robin state dictates right now (causing issues with many concurrent requests as exemplified in my initial report). The expected behavior here coming purely from an nginx perspective without any fancy Lua-based load balancing is that a different upstream would be picked.
If a pod is unreachable for long enough that readiness probes start to fail etc, the behavior will be "correct" in a real-life system since the affected pods will just be taken out of the rotation, but for the duration between the requests failing and the readiness probe registering that, the proxy_next_upstream
nginx config should work to bridge this gap. That does not apply here, however. As shown by my last comment, it is in fact completely broken for the consistent hashing case.
If you really want me to, I can come up with a reproduction case that does sometimes return a 200, but again, that will only obscure the actual problem and make it harder to reproduce reliably.
The real world case where we first encountered this was when a high traffic service had one of its pod crash and we had tons of requests failing despite several other pods for that service still being healthy. Our root cause analysis showed that the ingress was retrying the same upstream IP for the failing requests, but behaved "correctly" (according to proxy_next_upstream
) for others. I have opted for a much more simplified minimal reproducible example because getting circumstances just right otherwise (high enough traffic, have a pod crash at the right time etc.) is quite difficult.
Thanks @marvin-roesch , at least this discussion is adding info here, that could complete the triaging.
My request now to you is help me clarify what I understand from your comments. So I am cop/pasting a select set of words from your below ;
The problem is that ingress-nginx does not attempt any other upstream at all,
based on those words, I want to draw your attention one more time, to my understanding of your test. Both pods of the backend workload return 404. OR, all pods of the real-world scene, "system is in a bad state and thus all upstreams return an error"
. The bad response pods are removed from the list of upstreams. This means that there is no pod that qualifies or stays in the list of healthy available upstreams.
This makes me assume the controller trying the same last upstream 3 time instead of a new upstream is not abnormal. There is no other upstream that the controller can try.
Your expectation is in the below words ;
The problem is that ingress-nginx does not attempt any other upstream at all,
I am bothering you so much because how can the controller attempt any other upstream, if there are none in Ready state.
You can choose to ignore my comments if that is better because I am not a developer. To summarize if I can run a 3 replica workload and manually trick 2 of them to fail, we can get proof that there was at least one replica healthy and the controller should have routed to that healthy replica as next_upstream. And also there is shortage of resources as I mentioned, so community contributors here would be a help.
Also @marvin-roesch , just to reduce my repeated questions doing anything odd, I wanted to also clarify that I ack the below fact from you
but for the duration between the requests failing and the readiness probe registering that, the proxy_next_upstream nginx config should work to bridge this gap
And I am asking my question after reading this fact. As there is not a clear smoking gun data that shows the state of retrying same upstream pre & post this window of time.
@longwuyuan Thanks for trying to understand this! I'll try my best to answer your questions. I'll be picking out the sentences that I think need addressing, too.
The bad response pods are removed from the list of upstreams. This means that there is no pod that qualifies or stays in the list of healthy available upstreams.
This makes me assume the controller trying the same last upstream 3 time instead of a new upstream is not abnormal. There is no other upstream that the controller can try.
I think you've addressed this yourself in your following comment, but just to reiterate and confirm: Yes, there is no issue if Kubernetes has marked the affected pods as non-Ready and that change has propagated through to the nginx configuration. This can take several seconds though, which can be quite critical for a very high traffic system.
As there is not a clear smoking gun data that shows the state of retrying same upstream pre & post this window of time.
Yep, as outlined above, this is solely an issue for the short amount of time when the pods are in a bad state but the rest of the system has not been made aware of this yet. The ingress controller is working perfectly fine for us once that's the case.
To summarize if I can run a 3 replica workload and manually trick 2 of them to fail, we can get proof that there was at least one replica healthy and the controller should have routed to that healthy replica as next_upstream.
Yep, that would be the way to replicate this that's a little closer to the real-life example we encountered. Always responding the 404s (or any other error that proxy_next_upstream
is configured to retry on ) is functionally the same, however. The behavior is entirely defined by the configuration of the nginx instance that the ingress controller is running, the parts that are concerned with the Ingress API are not involved.
And also there is shortage of resources as I mentioned, so community contributors here would be a help.
I've been trying my best to point out the pertinent pieces of the code that cause this. The load balancing logic would somehow need to be made aware of which upstreams it has already tried. If using nginx's upstream
directive with server
instances directly, nginx takes care of all of that, but the Lua implementation completely circumvents that.
I'd be happy to contribute a fix for this, but I'm unsure about what approach to take. It might be easiest to pull what the EWMA load balancer is doing into the others, see the following snippets. I was surprised to find it already takes care of this. It seems this was addresses as part of solving #6632, but the issue still affects all other load balancers. https://github.com/kubernetes/ingress-nginx/blob/0111961e7d79b386c7717fa392d727e8fdadfc2a/rootfs/etc/nginx/lua/balancer/ewma.lua#L184-L190
It seems the summary is ;
for a high traffic use-case, there is a variable-length window of time between pod going away but endpointSlice yet-to-be updated.
this window breaks EASTABLISHED connections as well no new connections are possible, during this window, because the pod configured with the ip-address in the endpointSlice not returning 200s anymore
this only happens with round-robin loadbalancing
this does not happen with ewma
you are interested in contributing a fix
one helpful way forward is try a discussion in the ingress-nginx-dev channel of slack
request that you join the next community meeting as maintainers are there on the call
PS - Please express your thoughts on just using ewma for now
@longwuyuan That summary seems mostly correct to me, except for one thing: Looking at all the load balancer implementations, this affects the round-robin balancer as well as both consistent hashing ones (chash
and chashsubset
which will be used when using the upstream-hash-by
annotation). EWMA and the sticky session balancers are not affected, but they use different techniques for avoiding previously tried upstreams.
I'll check if EWMA works for our purposes in the meantime. Thanks for pointing me to the Slack, I have joined it and will create a thread to start discussion of potential ways forward. I think aligning all load balancer implementations is the way to go.
Thanks a lot @marvin-roesch . This is a action items overview that is getting tracked here so helps a lot.
I will copy/paste the summary and add the note you made.
Summary
for a high traffic use-case, there is a variable-length window of time between pod going away but endpointSlice yet-to-be updated.
this window breaks EASTABLISHED connections as well no new connections are possible, during this window, because the pod configured with the ip-address in the endpointSlice not returning 200s anymore
this only happens with round-robin loadbalancing
this affects the round-robin balancer as well as both consistent hashing ones (chash and chashsubset which will be used when using the upstream-hash-by
this does not happen with ewma. EWMA and the sticky session balancers are not affected, but they use different techniques for avoiding previously tried upstreams
you are interested in contributing a fix
one helpful way forward is try a discussion in the ingress-nginx-dev channel of slack
request that you join the next community meeting as maintainers are there on the call
you will update if temporary use of ewma works for you
cc @rikatz @tao12345666333 @strongjz @Gacko
/remove-triage needs-information /remove-kind support /kind bug
This is stale, but we won't close it automatically, just bare in mind the maintainers may be busy with other tasks and will reach your issue ASAP. If you have any question or request to prioritize this, please reach #ingress-nginx-dev
on Kubernetes Slack.
/remove-lifecycle frozen
This is stale, but we won't close it automatically, just bare in mind the maintainers may be busy with other tasks and will reach your issue ASAP. If you have any question or request to prioritize this, please reach #ingress-nginx-dev
on Kubernetes Slack.
/remove-lifecycle frozen
/triage accepted
@marvin-roesch this was discussed with @rikatz in the community meeting today. So please engage here or in the slack channel one more time. Sorry for the long wait. Its not good on the resources side of things.
Today's motivation was that we are going to address something similar in the context of timeouts/timing in https://github.com/kubernetes/ingress-nginx/pull/12397 but this use case is not the same as that PR
cc @strongjz @tao12345666333
What happened: When one backend pod fails under a condition covered by
proxy_next_upstream
(e.g.http_404
for easy testing), if there's a large volume of requests, any one request may reuse the same backend for all tries rather than actually using the "next" backend. This happens for sure with the default round-robin balancer, but most likely with all balancer implementations.What you expected to happen: If a backend request fails due to one of the
proxy_next_upstream
conditions, it should be retried with at least one of the other available backends, regardless of the configured load balancer or any concurrent requests.NGINX Ingress controller version (exec into the pod and run nginx-ingress-controller --version.): 1.11.2
Kubernetes version (use
kubectl version
): 1.28.10Environment:
Cloud provider or hardware configuration: MacBook Pro with Apple M2
OS (e.g. from /etc/os-release): Ubuntu 22.04.4 via Multipass on macOS 14.5
Kernel (e.g.
uname -a
): 5.15.0-119-genericInstall tools:
Basic cluster related info:
kubectl version
kubectl get nodes -o wide
How was the ingress-nginx-controller installed:
microk8s enable ingress
Current State of the controller:
kubectl describe ingressclasses
kubectl -n ingress get all -o wide
kubectl -n ingress describe po nginx-ingress-microk8s-controller-4hrss
Current state of ingress object, if applicable:
kubectl -n default get all,ing -o wide
kubectl -n <appnamespace> describe ing <ingressname>
Others:
How to reproduce this issue:
Install minikube/kind
Install the ingress controller
Install an application with at least 2 pods that will always respond with status 404
Create an ingress which tries next upstream on 404
Make many requests in parallel
Observe in the ingress controller's access logs (
kubectl logs -n ingress-nginx $POD_NAME
) that many requests will have the same upstream in succession in$upstream_addr
, e.g.Anything else we need to know: The problem is exacerbated by few (like 2 in the repro case) backend pods being hit by a large request volume concurrently. There is basically a conflict between global load balancing behaviour and per-request retries at play here. For e.g. the default round-robin load balancer, the instance is obviously shared by all requests (on an nginx worker) for a particular backend.
Assuming a system with 2 backend endpoints for the sake of simplicity, the flow of information can be as follows:
proxy_next_upstream
config requests another endpoint from the load balancing system, it gets routed to endpoint A by round robin balancerproxy_next_upstream
config requests another endpoint from the load balancing system, it gets routed to endpoint A by round robin balancerAs you can see, this means request 1 is only handled by endpoint A despite the
proxy_next_upstream
directive. Depending on the actual rate and order of requests etc, request 2 could have faced a similar fate, but request 3 came in before the initial response failed, so it happens to work out in that case.This makes
proxy-next-upstream
extremely unreliable and behave in unexpected ways. An approach to fixing this would be that the Lua-based load balancing be made aware of what endpoints have already been tried. The semantics are hard to nail down exactly, however, since this might break the guarantees that some load balancing strategies aim to provide. On the other hand, having the next upstream choice work reliably at all is invaluable for bridging over requests in a failure scenario. A backend endpoint might become unreachable, which should result in it eventually being removed from the load balancing once probes have caught up to the fact. In the meantime, the defaulterror timeout
strategy would try the "next" available upstream for any requests trying that endpoint, but if everything aligns just right, the load balancer would always return the same endpoint, resulting in a 502 despite the system at large being perfectly capable of handling the request.