hahait opened a new issue, #14149:
URL: https://github.com/apache/apisix/issues/14149

   ### Current Behavior
   
   Note on terminology: "APISIX pod" below means the pod running the APISIX 
gateway itself (where the nginx worker processes live). "backend pod" / 
"business pod" means a pod of the upstream service that APISIX is routing to, 
discovered via K8s Endpoints.
   
   With the kubernetes discovery module (single mode, classic Endpoints), one 
nginx worker process inside an APISIX pod can get stuck serving a stale node 
list for a service, while the other workers in that same APISIX pod serve the 
correct list. This sends a steady fraction of requests (roughly 1/worker_count) 
to Pod IPs that no longer exist, or to Pod IPs that have since been reassigned 
by K8s to a different backend service — causing 499 (timeout, when the old IP 
is simply unreachable) or 404 (when the old IP is now serving an unrelated 
service).
   
   We've hit this 4 times so far in production, on different services, each 
time right after a backend rollout or scale-down. In the worst case it lasted 
5.5+ hours on one worker, until someone manually deleted a backend (business) 
pod, which happened to fix it.
   
   Root cause (our theory, based on production debug evidence — not yet 
confirmed by a forced reproduction)
   
   To be clear upfront: what follows is our best explanation, built by reading 
the source and cross-checking it against the per-worker debug evidence 
described below. We have not forced the exact race in a controlled environment, 
so we'd like maintainers to confirm or correct this.
   
   In `init.lua`(3.10.0) / `core.lua` (3.18 / 3.19), writing a new Endpoints 
snapshot into the shared dict is two separate `safe_set` calls, and the version 
key is written first:
   
   ```lua
   -- 3.10.0, apisix/discovery/kubernetes/init.lua (on_endpoint_modified)
   local endpoint_content = core.json.encode(endpoint_buffer, true)
   local endpoint_version = ngx.crc32_long(endpoint_content)
   local _, err
   _, err = handle.endpoint_dict:safe_set(endpoint_key .. "#version", 
endpoint_version)  -- version first
   if err then
       core.log.error(...)
       return
   end
   _, err = handle.endpoint_dict:safe_set(endpoint_key, endpoint_content)       
         -- content second
   if err then
       handle.endpoint_dict:delete(endpoint_key .. "#version")
       core.log.error(...)
   end
   ```
   
   3.18 / 3.19:
   ```lua
   -- apisix/discovery/kubernetes/core.lua (_M.update_endpoint_dict)
   function _M.update_endpoint_dict(handle, endpoints, endpoint_key)
       local endpoint_content = core.json.encode(endpoints, true)
       local endpoint_version = ngx.crc32_long(endpoint_content)
       local _, err
       _, err = handle.endpoint_dict:safe_set(endpoint_key .. "#version", 
endpoint_version)  -- same order
       if err then
           return false, "set endpoint version into discovery DICT failed, " .. 
err
       end
       _, err = handle.endpoint_dict:safe_set(endpoint_key, endpoint_content)   
             -- same order
       if err then
           handle.endpoint_dict:delete(endpoint_key .. "#version")
           return false, "set endpoint into discovery DICT failed, " .. err
       end
       return true
   end
   ```
   
   Same logic, just moved into a shared helper. We didn't see any change to the 
write order or any added locking. Also checked the `3.18`and`3.19`branches 
directly, so upgrading may not help — would appreciate maintainers confirming 
this.
   
   Between the two writes there's a small window where the version key already 
points to the new version but the content key still holds the old content. A 
worker that reads during this window, through `single_mode_nodes` / 
`_M.resolve_nodes` → `fetch_valid_cache` in 
   `lrucache.lua`, ends up caching:
   ```lua
   { val = <old content>, ver = <new version> }
   ```
   
   `fetch_valid_cache` only checks `ver == version`, it doesn't re-check the 
content:
   ```lua
   -- apisix/core/lrucache.lua
   local function fetch_valid_cache(lru_obj, invalid_stale, item_ttl, 
item_release, key, version)
       local obj, stale_obj = lru_obj:get(key)
       if obj and obj.ver == version then
           return obj                      -- trusted once ver matches, content 
not rechecked
       end
       if not invalid_stale and stale_obj and stale_obj.ver == version then
           lru_obj:set(key, stale_obj, item_ttl)   -- just refreshes TTL
           return stale_obj
       end
       ...
   end
   ```
   
   Once the endpoints stop changing, the version stays the same, so this cached 
entry keeps passing the check forever — TTL expiry just refreshes it instead of 
forcing a re-read. The worker is stuck until the content changes again (new 
version → cache miss → re-fetch).
   
   This would explain:
   
   - only one worker inside a given APISIX pod being affected (each worker has 
its own lrucache; the shared dict itself is fine and consistent, we checked 
this directly below)
   - the stuck state not clearing on its own
   - deleting/restarting a backend (business) pod "fixing" it — it forces a 
real content change and a new version, giving every APISIX worker a fresh 
chance to re-cache correctly
   
   What we observed (production, APISIX 3.10.0, 4 nginx workers per APISIX pod)
   
   We used an isolated `serverless-pre-function` debug route (own uri, no 
upstream nodes, no production traffic) to check per-worker state via 
`ngx.worker.pid()` / `ngx.worker.id()`.
   - Calling discovery["kubernetes"].nodes(service_name) 100 times on the 
affected APISIX pod: workers 52/53/54 always returned 60 nodes (correct), 
worker 55 always returned 72 nodes (12 of them stale backend pod IPs), 100/100 
times.
   - Same check on a healthy APISIX pod (not showing the issue): all 4 workers 
returned 60 nodes every time.
   - Reading the raw version key from shm 
(ngx.shared["kubernetes"]:get_stale(key .. "#version")) alongside the same 
call, 100 samples: every worker, including worker 55, read the exact same 
version value. So this isn't a version mismatch or CRC32 collision between 
workers — the stuck worker's cached version really does match current, it's the 
content behind it that's wrong.
   - Checked require("apisix.router").http_routes() on the live route too: 
upstream.nodes count alternates 60/72 depending on which worker answers, 
modifiedIndex stays constant — so it's not an etcd/route change, it's inside 
discovery/upstream resolution.
   - Rough math: 1 stuck worker out of 4, 12 stale nodes out of 72 on that 
worker, ≈4.2% of requests — close to the 3.95%–4.4% bad-request ratio we saw in 
ELK during the incident.
   
   
   #8489 reports a similar symptom (backend pod IPs not updating, requests 
going to already-closed backend pods, APISIX 2.13.3), but the maintainers' 
theory there was a K8s-side timing issue between unregistering a backend pod 
from Endpoints and that pod actually terminating, with a preStop sleep hook 
suggested as a workaround. That issue went stale and was closed without a 
confirmed root cause.
   
   For context: the backend (business) services involved in our incidents 
already have a preStop sleep hook configured, and we still hit this issue, 
which is part of why we think the root cause is different — an APISIX-side 
caching race rather than a K8s backend-pod-termination timing problem.
   
   ### Expected Behavior
   
   The version and content should be updated atomically from a reader's point 
of view — no worker should ever be able to observe a state where the version 
already reflects a change but the content doesn't (or vice versa)
   ```lua
   _, err = handle.endpoint_dict:safe_set(endpoint_key, endpoint_content)       
  -- content first
   if err then ... return end
   _, err = handle.endpoint_dict:safe_set(endpoint_key .. "#version", 
endpoint_version)  -- version second
   if err then
       handle.endpoint_dict:delete(endpoint_key)   -- roll back content instead
       ...
   end
   ```
   
   It might also be worth having fetch_valid_cache not trust a version match 
forever without any re-check, since this kind of thing could affect other 
discovery backends too.
   
   ### Error Logs
   
   For the 499 requests, error.log shows upstream timed out (110: Connection 
timed out) while connecting to upstream — consistent with the IP being a 
stale/recycled pod that's no longer reachable. For the 404 requests, there's no 
error/warn log at all — those are just normal upstream responses from whatever 
is currently listening on the stale IP, so nginx has nothing to complain about. 
Either way, the worker itself never logs anything about serving a stale node 
list, which made this hard to notice from logs/alerts alone
   
   ### Steps to Reproduce
   
   We don't have a minimal repro — this is a low-probability race, but it's hit 
us 4 times in production so far, each time right after a rollout or a 
scale-down event on the affected backend (business) service. We haven't been 
able to reproduce it in a test environment. The root cause above is our best 
explanation based on the debug evidence from the live incidents (per-worker 
behaviour, shm version reads, router state), not something we've confirmed by 
forcing the race directly. Happy to share the debug route definitions if a 
maintainer wants to try reproducing it under concurrent Endpoints updates.
   
   ### Environment
   
   - APISIX version: 3.10.0 (same code pattern appears present in 3.18 / 3.19 
based on source review)
   - OS: Linux (APISIX pod running in Kubernetes)
   - Deployment: APISIX server deployed directly in our own K8s cluster (not 
via apisix-ingress-controller or similar)
   - Discovery: kubernetes, single mode, classic Endpoints (not EndpointSlice)
   - nginx worker_processes: 4 per APISIX pod


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to