Skip to content

Fix service discovery bug in kubernetes-extensions - #19139

Merged
gianm merged 16 commits into
apache:masterfrom
capistrant:k8s-discovery-bug
Mar 18, 2026
Merged

gianm merged 16 commits into
apache:masterfrom
capistrant:k8s-discovery-bug

Conversation

@capistrant

@capistrant capistrant commented Mar 11, 2026 •

Copy link
Copy Markdown
Contributor

Description

Bug Report

The k8s service discovery is not removing discovered nodes whose pods still exist with service announcement labels, but the underlying services are actually unhealthy.

For example, if a broker container is killed but the pod that manages it remains in the namespace with announcement labels, all druid services will maintain this service in their discovered services cache. This leads to queries being routed to a broker that cannot possibly execute the request. If this pod remains in an announced but unhealthy state for any meaningful period of time, the cluster functionality can be severely compromised.

Desired behavior in the above example would be that the broker is removed from discovered services caches, at least until the underlying container for the pod is restarted and the pod is healthy again.

Fix Details

My proposed fix starts using a pods readiness flag in the discovery logic. If a pod is not ready, the underlying services will not be added to service discovery caches they are not in and will be removed from any caches that they were in. These services can be added back once they have a MODIFIED or ADDED event in addition to being ready again.

Fix Risks

The biggest risk I see is that this new reliance on readiness probe introduces an expectation that this probe is accurate and stable. I try to call out in documentation that this needs to be considered when defining the readiness probe for a pod as a way to mitigate unexpected changes for users. This could be included in a release note as well to tip off any users of the extension.

Alternatives

Using Pod Phase is another option for this. This feels like a more permissive check than readiness in that it may be more lax with removing a service from discovered hosts than readiness.

The idea would be to treat Running and Unknown pod phase states as discoverable. Unknown is there to avoid k8s control plane communication issues / transient problems from removing hosts from discovered lists. One downside here based on k8s documentation (linked above) is that a pod will be in running phase if a container is in the process of starting or restarting. This technically means that services who are not actually ready to be in the cluster could still be discovered if they have the labels and are in a transient state trying to start. The fear here being that a service gets into a restart loop without changing phase, so it remains discoverable but is not going to become healthy in the near future (or ever).

Release note

TBD


Key changed/added classes in this PR
  • DefaultK8sApiClient
  • BaseNodeRoleWatcher
  • WatchResult

This PR has:

  • been self-reviewed.
  • added documentation for new or modified features or behaviors.
  • a release note entry in the PR description.
  • added Javadocs for most classes and all non-trivial methods. Linked related entities via Javadoc links.
  • added or updated version, license, or notice information in licenses.yaml
  • added comments explaining the "why" and the intent of the code wherever would not be obvious for an unfamiliar reader.
  • added unit tests or modified existing tests to cover new code paths, ensuring the threshold for code coverage is met.
  • added integration tests.
  • been tested in a test Druid cluster.

@capistrant

Copy link
Copy Markdown
Contributor Author

marking this as draft while I evaluate a competing approach that uses pod phase instead of readiness

@capistrant
capistrant marked this pull request as draft March 11, 2026 22:32
@capistrant
capistrant marked this pull request as ready for review March 17, 2026 22:31
@capistrant capistrant added this to the 37.0.0 milestone Mar 17, 2026
@capistrant

Copy link
Copy Markdown
Contributor Author

marking this as draft while I evaluate a competing approach that uses pod phase instead of readiness

I think that I prefer readiness to pod phase.

being ready means that you are ready to announce yourself to the cluster. If a readiness probe is properly configured using our recommendations, if the probe fails enough to cross threshold of being not ready, the service is no longer ready to be announced and should stop getting traffic.

also in protecting against the worst case of a pod having announce labels but the container having been killed without proper shutdown + not being rescheduled, readiness will properly handle undiscovering such a service nearly immediately upon crash.

effectiveType = WatchResult.NOT_READY;
} else if (WatchResult.ADDED.equals(item.type)) {
// Pod is not ready yet (e.g., still starting up). Skip this event entirely.
// It will appear via a MODIFIED event that remaps to ADDED for dicovery, once it becomes ready.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discovery (spelling)

@@ -26,9 +26,18 @@
public interface WatchResult

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class used to be a relatively thin layer over k8s watches, now there's remapping and synthetic events going on. The javadoc of this class should make clear that it's not meant to be a thin layer over k8s watches, it's meant to be aligned with the needs of service discovery.

@@ -273,6 +273,11 @@ private void keepWatching(String labelSelector, String resourceVersion)
case WatchResult.DELETED:
baseNodeRoleWatcher.childRemoved(item.object.getNode());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can DELETED fire after NOT_READY (or even before ADDED, if a pod is deleted before it ever becomes ready)? I think the logging in those cases will end up noisy, since childRemoved complains loudly if the service doesn't exist. You added a skipIfUnknown that is used when NOT_READY fires, but I wonder if now we should always act like that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, these could certainly happen. thank you for calling it out

@gianm
gianm merged commit 15e98ba into apache:master Mar 18, 2026
37 checks passed
airlock-confluentinc Bot pushed a commit to confluentinc/druid that referenced this pull request Jul 1, 2026
saithal-confluent added a commit to confluentinc/druid that referenced this pull request Jul 8, 2026
…ix (apache apache#19139) to druid 30 (#469)

* OBSDATA-13995 Wire TCP connect timeout into Netty HTTP client

The Netty ClientBootstrap had no connectTimeoutMillis set, so TCP
SYN to an unreachable host would hang for the OS default (~2 min
on Linux). The existing druid.broker.http.clientConnectTimeout
config (default 500ms) was only applied to the Jetty client used
by the Router, not the Netty client used by the Broker.

Thread connectTimeout through HttpClientConfig into the Netty
bootstrap via bootstrap.setOption("connectTimeoutMillis", ...).
Queries to unreachable Historicals now fail in 500ms instead of
hanging for minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix service discovery bug in `kubernetes-extensions` (apache#19139)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lucas Capistrant <capistrant@users.noreply.github.com>
saithal-confluent added a commit to confluentinc/druid that referenced this pull request Jul 8, 2026
… broker TCP connect timeout (#450)

* OBSDATA-13995 Wire TCP connect timeout into Netty HTTP client

The Netty ClientBootstrap had no connectTimeoutMillis set, so TCP
SYN to an unreachable host would hang for the OS default (~2 min
on Linux). The existing druid.broker.http.clientConnectTimeout
config (default 500ms) was only applied to the Jetty client used
by the Router, not the Netty client used by the Broker.

Thread connectTimeout through HttpClientConfig into the Netty
bootstrap via bootstrap.setOption("connectTimeoutMillis", ...).
Queries to unreachable Historicals now fail in 500ms instead of
hanging for minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix service discovery bug in `kubernetes-extensions` (apache#19139)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lucas Capistrant <capistrant@users.noreply.github.com>
saithal-confluent added a commit to confluentinc/druid that referenced this pull request Jul 20, 2026
…oker connect timeout (#474)

Scoped revert of the kubernetes-extensions / service-discovery half of
455f3e5 (OBSDATA-13995 Backport connect timeout (#450) and service
discovery fix (apache apache#19139) to druid 30 (#469)). The broker TCP
connect-timeout fix (HttpClientConfig.java, HttpClientInit.java,
HttpClientModule.java, DruidHttpClientConfig.java) is untouched and
remains in place - it is unrelated and unaffected by this incident.

The isPodReady() filter added to DefaultK8sApiClient.listPods()/
watchPods() requires k8s to already report a pod's container ready
before that pod can be treated as self-discovered. But a pod's
readiness/startup probe (still pointed at /status/selfDiscovered on
every environment using this image) only passes once self-discovery
succeeds - a circular dependency. This crash-restart-looped every
overlord pod on druid-devel-44409 (running druid-34, which carries
the identical filter) after it picked up an image containing this
filter; druid-30 is exposed to the same risk on its next fresh pod
rollout.

The real fix (a new /status/ready endpoint backed by a purely local
ServiceAnnouncementState flag, breaking the circularity) is up as
PR #472 on this branch's line, but the filter and the fix must ship
in the same image tag - landing them in different tags is what
caused the incident, and leaving the filter in now blocks other
in-flight work on this branch. Reverting here to unblock; the filter
will be re-added together with PR #472 once both are ready to ship
as a single image.

This reverts the isPodReady() filter and its tests to their pre-455f3e5715
state; docs/development/extensions-core/kubernetes.md and
website/.spelling are reverted alongside since both are attributable
solely to the service-discovery change.
saithal-confluent added a commit to confluentinc/druid that referenced this pull request Jul 20, 2026
…oker connect timeout (#473)

Scoped revert of the kubernetes-extensions / service-discovery half of
b0f841b ([OBSDATA-13995] Backport service discovery fix (apache#19139) and
add broker TCP connect timeout (#450)). The broker TCP connect-timeout
fix (HttpClientConfig.java, HttpClientInit.java, HttpClientModule.java)
is untouched and remains in place - it is unrelated and unaffected by
this incident.

The isPodReady() filter added to DefaultK8sApiClient.listPods()/
watchPods() requires k8s to already report a pod's container ready
before that pod can be treated as self-discovered. But a pod's
readiness/startup probe (still pointed at /status/selfDiscovered on
every environment using this image) only passes once self-discovery
succeeds - a circular dependency. This crash-restart-looped every
overlord pod on druid-devel-44409 after it picked up an image
containing this filter.

The real fix (a new /status/ready endpoint backed by a purely local
ServiceAnnouncementState flag, breaking the circularity) is up as
PR #471 on this branch's line, but the filter and the fix must ship
in the same image tag - landing them in different tags is what
caused the incident, and leaving the filter in now blocks other
in-flight work on this branch. Reverting here to unblock; the filter
will be re-added together with PR #471 once both are ready to ship
as a single image.

This reverts the isPodReady() filter and its tests to their pre-b0f841bfb3
state; docs/development/extensions-core/kubernetes.md and
website/.spelling are reverted alongside since both are attributable
solely to the service-discovery change.
vivek807 pushed a commit to deep-bi/druid that referenced this pull request Sep 1, 2026
vivek807 pushed a commit to deep-bi/druid that referenced this pull request Sep 1, 2026
vivek807 pushed a commit to deep-bi/druid that referenced this pull request Sep 1, 2026
airlock-confluentinc Bot pushed a commit to confluentinc/druid that referenced this pull request Sep 18, 2026
Backport of upstream commit ee614dc
(apache#19148), which predates and is required by the
service-discovery isPodReady() filter backported in b0f841b
([OBSDATA-13995] Backport service discovery fix (apache#19139)...).

Without this endpoint, a pod's readiness/startup probe pointed at
/status/selfDiscovered deadlocks: k8s only marks a container ready
after its probe passes, but the k8s-discovery isPodReady() filter
requires k8s to already report the container ready before
self-discovery (and therefore /status/selfDiscovered) can succeed.
This surfaced as a crash-restart loop on druid-devel-44409 overlord
pods immediately after rolling to an image containing the isPodReady()
filter.

This commit adds ServiceAnnouncementState, a purely local flag flipped
by the node's own announce()/unannounce() lifecycle handlers, and
exposes it via a new /status/ready endpoint that upstream now
recommends as the probe target instead of /status/selfDiscovered
(see the docs update in apache#19139). Wiring probes to /status/ready
is a separate, follow-up change to the helm chart / operator defaults.

(cherry picked from commit ee614dc)
saithal-confluent added a commit to confluentinc/druid that referenced this pull request Sep 18, 2026
…#495)

* [OBSDATA-13995] Backport /status/ready endpoint (apache apache#19148)

Backport of upstream commit ee614dc
(apache#19148), which predates and is required by the
service-discovery isPodReady() filter backported in b0f841b
([OBSDATA-13995] Backport service discovery fix (apache#19139)...).

Without this endpoint, a pod's readiness/startup probe pointed at
/status/selfDiscovered deadlocks: k8s only marks a container ready
after its probe passes, but the k8s-discovery isPodReady() filter
requires k8s to already report the container ready before
self-discovery (and therefore /status/selfDiscovered) can succeed.
This surfaced as a crash-restart loop on druid-devel-44409 overlord
pods immediately after rolling to an image containing the isPodReady()
filter.

This commit adds ServiceAnnouncementState, a purely local flag flipped
by the node's own announce()/unannounce() lifecycle handlers, and
exposes it via a new /status/ready endpoint that upstream now
recommends as the probe target instead of /status/selfDiscovered
(see the docs update in apache#19139). Wiring probes to /status/ready
is a separate, follow-up change to the helm chart / operator defaults.

(cherry picked from commit ee614dc)

* Fix embedded-tests MinIO image pull failure by switching to quay.io

minio/minio was removed from Docker Hub (INC-13385), breaking
MinIOStorageResource-based embedded tests. Point at the equivalent
quay.io/minio/minio tag, matching the fix already adopted elsewhere
(e.g. confluentinc/flink-control-plane#13593).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Revert "Fix embedded-tests MinIO image pull failure by switching to quay.io"

This reverts commit f797bdd.

* ci: switch MinIO test image from Docker Hub to Quay

* Drop ServerSideEncryptingAmazonS3MultipartUploadTest cherry-pick

This upstream test (from apache#20331) targets the SDK v2-style
ServerSideEncryptingAmazonS3/S3Utils API that isn't present on this
34.0.0-confluent fork yet (still on AWS SDK v1), so it doesn't compile.
Keeping just the MinIO image fix for MinIOStorageResource.java and
S3StorageConnectorTest.java, which is what's needed to unblock PR #495.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Gian Merlino <gianmerlino@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Frank Chen <frank.chen021@outlook.com>
vivek807 pushed a commit to deep-bi/druid that referenced this pull request Oct 8, 2026
FrankChen021 pushed a commit that referenced this pull request Oct 8, 2026
* Restarted the watch after stream reset

* Addressed review comments

* Add unit test and addresses styling issues

* Fix service discovery bug in `kubernetes-extensions` (#19139)

* Address review comments.
Remove hard dependency of okhttp3 internals

* fix: remove duplicate JUnit4 tests reintroduced by rebase

Rebasing onto apache/druid master silently duplicated three
childRemoved(skipIfUnknown) tests: the JUnit5 versions already in this
branch's history merged alongside stale JUnit4 copies from before the
junit5-migration commit, instead of conflicting.

* Handle Bookmark event
Address review comments

Signed-off-by: Vivek Dhiman <approach2vivek@gmail.com>

---------

Signed-off-by: Vivek Dhiman <approach2vivek@gmail.com>
Co-authored-by: Lucas Capistrant <capistrant@users.noreply.github.com>
saithal-confluent added a commit to confluentinc/druid that referenced this pull request Oct 30, 2026
…9139) on Druid 34 (#504)

* [OBSDATA-15475] Re-land service-discovery isPodReady() filter (apache#19139)

Reverts 5568688 (#473). The /status/ready endpoint (apache#19148,
#495) is now in this branch's ancestry, so any tag containing the filter
also contains the endpoint.

Do not roll a tag built from this to a cluster that has not completed its
Phase B probe-path flip (OBSDATA-15474); the filter plus probes still on
/status/selfDiscovered reproduces the 2026-07-20 devel-44409 overlord
crash-loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Reduce log noise for k8s NodeRoleWatcher (apache#19077)

(cherry picked from commit 010be96)

* [OBSDATA-15475] Address review: Ready condition, BOOKMARK rv, relist backoff, tests

- isPodReady requires the pod Ready condition plus all containers ready, so
  node-lost pods (stale container statuses) and not-ready sidecars are dropped.
- Port the BOOKMARK resourceVersion fix from apache#19056.
- Back off watcherErrorRetryWaitMS before relisting on a null watch item.
- Add watchPods tests against a mock watch stream and expand isPodReady tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [OBSDATA-15475] Apply terminatingStateCheckDuration to watch path

The MODIFIED->ADDED remap re-added stuck-terminating pods that listPods had
excluded. Share the termination rule between list and watch: past the check
duration, MODIFIED maps to NOT_READY and ADDED is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Lucas Capistrant <capistrant@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants