Visitar URL original
Detect silent connection loss behind the cloud load balancers by nolar · Pull Request #1267 · nolar/kopf · GitHub
Skip to content

Detect silent connection loss behind the cloud load balancers - #1267

Merged
nolar merged 3 commits into
mainfrom
watch-bookmarks
Mar 10, 2026
Merged

nolar merged 3 commits into
mainfrom
watch-bookmarks

Conversation

@nolar

@nolar nolar commented Mar 9, 2026 •

Copy link
Copy Markdown
Owner

Problem: There is a well-known and frequent issue: the watch-stream sometimes becomes silent after 5-10 mins of inactivity, especially on the low-load clusters. This usually happens when any kind of cloud-hosted k8s is used like this: K8s<>LB<>Kopf.

The primary hypothesis (unproven) is that it is caused by the load balancer losing the connection K8s<>LB, but keeping the connection LB<>Kopf alive. As a result, Kopf (aiohttp, even the OS) does not know that the watch-stream is not alive anymore, and therefore does not reconnect.

UPD: The users of Kopf reported a different analysis, where the connection is lost not because of a LB, which they do not have, but because of changing the internal IP addresses out of a pool of several of them. This does not change anything for Kopf: the effects are the same (silently dead connections), the fix is the same (inactivity tracking).

A helpful external issue:

Workaround: A typical workaround is to set the client-side timeout to anything 1-10-20 minutes, but there is no good default that suits everyone — it depends on the cluster size and load. The downside is that we reconnect regardless of whether the connection is semi-dead or actually alive.

Solution: Now, it will activate k8s BOOKMARK events in the watch-stream, available since K8s 1.15 (the latest now is 1.35).

The bookmarks are hard-coded to happen every 60 seconds (currently non-configurable):

So, if there are no BOOKMARK events for longer than 60+ (e.g. 70) seconds, we can reliably conclude that the watch-stream is silently closed, so we can explicitly close it on our side and reconnect.

In the normal flow of operations, while the connection is alive, the BOOKMARK events will arrive, keeping the same original connection as long as possible.

Scope: Fixed only in Python 3.11+. It requires asyncio.timeout() and fails terribly with asyncio.wait_for() in tests. It is easier to wait ≈7 months till the Python 3.10 end-of-life (Oct 2026) than to build a complicated solution for it, which is already available in Python 3.11+.

Python 3.10 will work the old way — with potential freezes, ignoring the inactivity timeout. Nevertheless, Python 3.10 users can benefit from the BOOKMARK events flowing every 60 seconds and thus keeping the TCP connection alive — even without timeouts and reconnections.

Difference: This differs from the workaround with the same 60-second timeout (or any other default timeout) on the client connection, since we do not reconnect if the connection remains alive and the events are streaming. Frequent unnecessary reconnections were the main flaw in the workaround with timeouts.

Side benefit 1: Moreover, the mere existence of the BOOKMARK events might work as a keep-alive for the TCP connection (some traffic going), so it will not disconnect on the K8s<>LB part due to TCP inactivity — but this is a blind guess, no proof that it works or breaks that particular way, the safeguard for the reconnection is still needed.

Side benefit 2: Among other things, though not the goal, it helps keeping the latest resource version closer in time rather than the current resource version from the actual latest event — so the reconnections will use the time window of approximately 1 minute on the server side.

@nolar nolar added the bug Something isn't working label Mar 9, 2026
@nolar
nolar force-pushed the watch-bookmarks branch from ea0e640 to e562da4 Compare March 9, 2026 20:22
Comment thread tests/reactor/test_queueing.py Fixed
nolar and others added 3 commits March 9, 2026 21:26
Enable Kubernetes BOOKMARK events by requesting them with `allowWatchBookmarks=true` and processing them in the watch stream. Bookmark events update the last seen resource version (ensuring reconnects resume from the right point) and are yielded upstream for future use by the watcher/worker layer. This is a prerequisite for detecting silent connection losses behind load balancers by monitoring whether bookmark events arrive within an expected interval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
Kubernetes sends BOOKMARK events as a heartbeat roughly every 60 seconds. If the watch stream goes silent — delivering no events at all, not even bookmarks — for longer than settings.watching.inactivity_timeout (default 90 seconds), the stream is considered dead. watch_objs() then closes it and returns, allowing continuous_watch() to reconnect automatically with the latest known resource version.

This handles the case where the TCP connection appears alive at the network level but the server has stopped sending data, which would not produce a ClientConnectionError or asyncio.TimeoutError on its own.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
…r Python 3.11+

asyncio.wait_for(anext(stream), timeout=N) created an inner Task per iteration via ensure_future. In tests using the looptime fake clock, the mocked stream does asyncio.sleep(0) which uses call_soon callbacks. These kept the event loop's ready queue perpetually non-empty, preventing the fake clock from ever advancing — causing the orchestration test (test_new_resources_and_namespaces_spawn_new_tasks) to freeze indefinitely in real time.

asyncio.timeout() (available since Python 3.11) avoids this by scheduling cancellation of the outer task directly via loop.call_later, with no inner Tasks. After each received event the deadline is rescheduled via timeout_cm.reschedule(), so only genuine silence triggers the timeout. On Python 3.10 we fall back to watch_objs_python_3_10() which has no inactivity protection but at least does not break tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
@nolar
nolar force-pushed the watch-bookmarks branch from e562da4 to d2b27f7 Compare March 9, 2026 21:47
Comment thread tests/reactor/test_queueing.py Dismissed
@giannicarabelli

Copy link
Copy Markdown

Hi all. After some investigation, I think I've identified the root cause — applies to AWS EKS but likely elsewhere too.

Context

In EKS there are no load balancers: AWS requires relying on FQDNs rather than IPs. Each zone has its own node, with its own IP, and those IPs are not stable — AWS can change them at any time.

The problem

The default/kubernetes service holds the control plane IPs as endpoints (e.g. x and y). When AWS rotates them (e.g. y → k), the old node y can die without cleanly closing TCP connections, leaving them stale.

Under the hood, the service uses iptables for DNAT. When endpoints change, the iptables rules are updated — but existing connections are unaffected and keep believing they're alive.

Since the socket has no SO_KEEPALIVE, the connection hangs forever.

Possible fixes

  1. Enable SO_KEEPALIVE at OS level — but default kernel parameters are usually too large for this use case.
  2. Enable SO_KEEPALIVE with custom parameters directly in the code, for finer control. I did this to monkey patch _aiohttp.TCPConnector.create_connection and seems ok.
  3. Forget what's happening under the hood and use BOOKMARK to detect inactivity, as proposed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants