Skip to content

optionally use watch events to track k8s pods - #917

Open
pjfanning wants to merge 7 commits into
apache:mainfrom
pjfanning:pod-scale
Open

pjfanning wants to merge 7 commits into
apache:mainfrom
pjfanning:pod-scale

Conversation

@pjfanning

@pjfanning pjfanning commented Jul 31, 2026

Copy link
Copy Markdown
Member

Motivation

kubernetes-api discovery performs a full pod list GET on every lookup. In a large cluster with frequent lookups that puts avoidable load on the API server, and the cost grows with the number of pods rather than with the number of changes.

The Kubernetes Watch API is the intended alternative: list once, then receive incremental ADDED / MODIFIED / DELETED events over a long-lived stream.

Modification

Add api-poll-mode, defaulting to "list" so existing behaviour is unchanged. With "watch", the first lookup performs an initial list to seed a cache and fix the point the watch resumes from, then opens a watch stream; later lookups read the cache without touching the API server.

Supporting changes:

  • PodList gains ListMeta, so the watch can resume from the list's resourceVersion. An individual pod's version is not a valid resume point — in the existing pods.json fixture the list is at 16042 while the last item is at 7406832, and resuming from the latter would silently skip every event in between.
  • Watch state (podCache, resourceVersion, kill switch) is held per label selector in a ConcurrentHashMap. One discovery instance can be asked about several service names, each mapping to its own selector, so sharing the cache would let one service's lookup return another service's pods.
  • The stream is framed with Framing.delimiter over the raw bytes, bounded by watch-max-frame-length. Decoding each network chunk to a String before splitting corrupts any multi-byte UTF-8 character straddling a chunk boundary, and the resulting parse failure is easy to swallow as a dropped event.
  • Reconnects rebuild from a full list whenever there is no valid resume point (410 Gone, or an ERROR event), so pods deleted while disconnected do not linger in the cache. Resuming the stream alone would leave them there indefinitely.
  • lookup honours resolveTimeout in watch mode, as it already did in list mode.
  • Pods without metadata.name are dropped rather than sharing a cache key.
  • api-poll-mode and watch-max-frame-length are validated in Settings, so a typo such as "Watch" fails at startup instead of silently falling back to list mode.
  • Watch streams are shut down in the service-unbind phase of Coordinated Shutdown, and reconnects stop once shutdown has begun.

Docs cover watch mode, the new settings, and the RBAC requirement (watch alongside list).

Result

Discovery can track pods from a single streaming connection instead of polling, with list mode untouched as the default.

Tests

  • sbt "discovery-kubernetes-api/test" — 27 succeeded, 0 failed.

  • KubernetesApiWatchSpec covers the cache transitions, resource-version tracking, ERROR handling, unnamed pods, per-selector isolation, and framing a multi-byte character split across two chunks.

  • JsonFormatSpec asserts the list resourceVersion, which differs from every item's.

  • SettingsSpec covers the new settings and their validation.

  • Integration test against a real API server. test-watch.sh deploys the demo with api-poll-mode = watch and reuses the shared Kubernetes script, then asserts the cluster formed via the watch stream rather than by quietly falling back to list mode. It runs as a second job in the Kubernetes API workflow — a separate job rather than a matrix, so the existing job keeps its name for branch protection. Passing in CI:

    KubernetesApiServiceDiscovery - Watch stream started for label selector: [app=pekko-bootstrap-demo]
    ClusterWatcher - Cluster pekko://Appka@10.244.0.7:17355 >>> MemberUp(...)
    3 pod(s) started a Kubernetes watch stream
    
  • sbt "discovery-kubernetes-api/mimaReportBinaryIssues" — success; PodList and Metadata gained fields, with excludes in watch-pod-changes.excludes.

  • sbt "+headerCheckAll" and scalafmt checks — clean.

References

None - new feature

@pjfanning
pjfanning marked this pull request as draft July 31, 2026 21:32
…very conflict

Kept AtomicReference and ConcurrentHashMap imports needed for the watch
cache (PR apache#917). Dropped unused SSL imports (KeyStore, SecureRandom,
KeyManager, KeyManagerFactory, SSLContext, TrustManager) that were
refactored to PemManagersProvider on main.
Motivation:
Review of the watch mode added in this branch turned up several problems.

The pod cache and the watch resource version were single fields on the discovery
instance, while `startedWatches` was keyed by namespace and label selector. One
instance can be asked about more than one service name, and each service name
maps to its own label selector, so two watches wrote into one cache: a lookup for
service A could return service B's pods, and the two streams overwrote each
other's resume position.

The watch stream decoded each network chunk with `_.utf8String` before splitting
on newlines. A chunk boundary can fall in the middle of a multi-byte UTF-8
character, which corrupts it, and the resulting parse failure was swallowed as a
warning, silently dropping the event. The hand-rolled line buffer also grew
without bound if no newline arrived, and re-split the whole buffer per chunk.

The watch resumed from the last item's `resourceVersion` rather than the list's
own. These are not the same value - in the existing `pods.json` fixture the list
is at 16042 while the last item is at 7406832 - and resuming from an item's
version can silently skip every event between it and the end of the list. The
`.orElse(Some("0"))` fallback is also not a valid resume point.

Reconnecting only ever restarted the stream, so when the resume point was
discarded (410 Gone, or an ERROR event) the cache was never rebuilt and pods
deleted while disconnected stayed in it forever. ERROR events were logged as
"unexpected" and otherwise ignored.

`lookupWatch` ignored `resolveTimeout`, which `lookupList` honours, so a hung
initial list hung the caller. Pods with no `metadata.name` were all cached under
the key "unknown", overwriting each other. `api-poll-mode` was an unvalidated
string, so a typo such as "Watch" silently fell back to list mode. Nothing shut
the streams down, and the reconnect loop rescheduled itself indefinitely.

Modification:
Move the per-selector state into a `WatchState` held in a `ConcurrentHashMap`
keyed by namespace and label selector, so each selector has its own cache,
resume position and kill switch.

Frame the stream with `Framing.delimiter` over the raw bytes and decode each
frame, bounded by a new `watch-max-frame-length` setting. Add `ListMeta` to
`PodList` and resume from the list's `resourceVersion`, falling back to no
version rather than "0". Rebuild from a full list whenever there is no resume
point, and treat an ERROR event as a signal to do so. Apply `resolveTimeout` to
`lookupWatch`. Drop pods with no name instead of collapsing them onto one key.
Validate `api-poll-mode` and `watch-max-frame-length` in `Settings`. Register a
`PhaseServiceUnbind` task that stops the streams and prevents further reconnects.

Move the Watch Mode docs section out from between "This configuration complements
the following Deployment specification:" and the Deployment YAML it refers to.

Result:
Two service names looked up through one discovery instance no longer see each
other's pods. Watch events survive chunk boundaries, the watch resumes from a
valid point or re-lists, lookups honour their timeout, and the streams stop on
shutdown.

Tests:
- sbt "discovery-kubernetes-api/test" - 27 succeeded, 0 failed
- New KubernetesApiWatchSpec covers the cache transitions, resource-version
  tracking, ERROR handling, unnamed pods, per-selector isolation, and framing a
  multi-byte character split across two chunks
- JsonFormatSpec now asserts the list resourceVersion (16042), which differs from
  every item's
- New SettingsSpec cases for api-poll-mode and watch-max-frame-length validation
- sbt "discovery-kubernetes-api/mimaReportBinaryIssues" - success
- sbt "discovery-kubernetes-api/scalafmtCheck" "discovery-kubernetes-api/Test/scalafmtCheck" - clean
- Not run against a live Kubernetes cluster - no cluster available

References:
Refs apache#917
Motivation:
The watch mode has unit coverage for its cache, framing and settings, but nothing
had run it against a real Kubernetes API server. The existing
`integration-test/kubernetes-api` suite only exercises list mode, so a watch that
never connects, or one that silently degrades, would not be caught.

Modification:
Add `pekko-cluster-watch.yml`, which deploys the same demo app with
`-Dpekko.discovery.kubernetes-api.api-poll-mode=watch` in `JAVA_OPTS`. Its RBAC
role is the same as the list-mode one, which already grants `watch` alongside
`list`, so no extra permission is needed.

Add `test-watch.sh`, which reuses `integration-test/scripts/kubernetes-test.sh`
to form the cluster and assert 3 MemberUp events, then greps the pod logs for
"Watch stream started for label selector". That second assertion matters because
discovery falls back to list mode for any api-poll-mode it does not recognise, so
without it a broken watch could still pass by quietly listing instead.

Run it from a second job in the Kubernetes API workflow. A separate job rather
than a matrix, so that the existing job keeps its name and any branch protection
that refers to it.

Result:
Watch mode is exercised end to end against a real API server: initial list,
watch stream, and cluster formation from the resulting cache.

Tests:
- sbt "+headerCheckAll" - success
- Verified the property reaches the setting rather than assuming the JAVA_OPTS
  path works: sbt -Dpekko.discovery.kubernetes-api.api-poll-mode=watch
  "discovery-kubernetes-api/testOnly *SettingsSpec" makes "default api-poll-mode
  to list" fail with "[watch]" was not equal to "[list]"
- bash -n on test-watch.sh, and both YAML files parse
- The integration test itself has not been run locally - it needs minikube, so
  this is a best-effort first pass to be validated by CI

References:
Refs apache#917
Motivation:
test-watch.sh was given the Pekko header that mentions Akka, copied from the
test.sh it was based on. Measured against that file it does not warrant it: of
its 21 substantive lines only 5 are shared with test.sh, and those five are
`set -exu`, three variable assignments whose values are all Pekko-specific, and
the call to integration-test/scripts/kubernetes-test.sh. The other 16 lines, and
the reason the file exists, are new. AGENTS.md reserves the Akka header for files
copied from Akka-derived sources.

pekko-cluster-watch.yml had no header at all.

Modification:
Put the standard Apache header on both, matching the form already used by the
Pekko-original YAML in .github (sync-nightlies/action.yml,
stage-release-candidate.yml).

Result:
Both new files carry the header that reflects their provenance.

Tests:
- sbt "+headerCheckAll" - success, though note it does not cover .sh or .yml
  (project/CopyrightHeader.scala maps only scala, java, conf and template), so
  this was a manual check
- bash -n on test-watch.sh, and pekko-cluster-watch.yml still parses as three
  documents (Deployment, Role, RoleBinding)

References:
Refs apache#917
@pjfanning
pjfanning marked this pull request as ready for review August 25, 2026 17:54
@pjfanning pjfanning changed the title WIP: optionally use watch events to track k8s pods optionally use watch events to track k8s pods Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant