This is an automated email from the ASF dual-hosted git repository.

jojochuang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new 11ba2a1f13c HDDS-16360. Add liveness and readiness health endpoints to 
S3 Gateway (#11224)
11ba2a1f13c is described below

commit 11ba2a1f13c8d4fd9fb1f030f1fd7bad425fcfb3
Author: Andrey Yarovoy <[email protected]>
AuthorDate: Thu Sep 17 18:00:55 2026 -0400

    HDDS-16360. Add liveness and readiness health endpoints to S3 Gateway 
(#11224)
    
    Generated-by: Claude Code (Claude Opus 4.8)
---
 hadoop-hdds/docs/content/interface/S3.md           |  24 +++
 .../ozone/conf/S3GatewayHealthCheckConfig.java     |  95 ++++++++++++
 .../dist/src/main/compose/common/s3-haproxy.cfg    |  11 +-
 hadoop-ozone/dist/src/main/compose/ozone/test.sh   |   2 +
 .../src/main/compose/ozonesecure-ha/s3-haproxy.cfg |  11 +-
 hadoop-ozone/dist/src/main/compose/testlib.sh      |  13 ++
 .../src/main/k8s/definitions/ozone/s3g-ss.yaml     |   6 +-
 .../examples/getting-started/s3g-statefulset.yaml  |   6 +-
 .../k8s/examples/minikube/s3g-statefulset.yaml     |   6 +-
 .../k8s/examples/ozone-dev/s3g-statefulset.yaml    |   6 +-
 .../k8s/examples/ozone-ha/s3g-statefulset.yaml     |   6 +-
 .../main/k8s/examples/ozone/s3g-statefulset.yaml   |   6 +-
 .../dist/src/main/smoketest/s3/health.robot        |  43 ++++++
 .../hadoop/ozone/s3/TestS3GatewayHealthCheck.java  | 126 ++++++++++++++++
 hadoop-ozone/s3gateway/pom.xml                     |   4 +
 .../hadoop/ozone/s3/S3GatewayReadinessProbe.java   | 164 +++++++++++++++++++++
 .../hadoop/ozone/s3/S3GatewayWebAdminServer.java   |  81 ++++++++++
 .../ozone/s3/TestS3GatewayLivenessServlet.java     | 126 ++++++++++++++++
 18 files changed, 724 insertions(+), 12 deletions(-)

diff --git a/hadoop-hdds/docs/content/interface/S3.md 
b/hadoop-hdds/docs/content/interface/S3.md
index b43abb13db5..5fb5f4edbd0 100644
--- a/hadoop-hdds/docs/content/interface/S3.md
+++ b/hadoop-hdds/docs/content/interface/S3.md
@@ -161,6 +161,30 @@ ozone s3 revokesecret -u om/[email protected] -y
 S3 secret revoked.
 ```
 
+## Health check
+
+The S3 Gateway serves two unauthenticated health endpoints on its web admin 
server (default port `19878`), separate from the S3 listener (`9878`). Because 
they are served on the admin port, they do not collide with S3 bucket names and 
do not require AWS SigV4 signing, so a load balancer can poll them directly.
+
+### Liveness
+
+```bash
+curl http://localhost:19878/health/live
+OK
+```
+
+Returns `200 OK` while the gateway process is up and its web admin server can 
serve requests. It does not check OM reachability.
+
+### Readiness
+
+```bash
+curl http://localhost:19878/health/ready
+READY
+```
+
+Returns `200 READY` only when the gateway can reach OM, and `503 NOT READY` 
during startup or while OM is unreachable. A background probe refreshes OM 
reachability off the request path, so the endpoint always responds immediately 
and cannot be blocked by a slow OM. The probe cadence and its timeout are 
tunable with `ozone.s3g.health-check.probe.interval` and 
`ozone.s3g.health-check.probe.timeout`.
+
+When S3 Gateway is horizontally scaled, point the load balancer's HTTP health 
check at the readiness endpoint so it only routes S3 traffic to gateways that 
can serve it; see the bundled `compose/common/s3-haproxy.cfg` for a worked 
HAProxy example. Both endpoints can be turned off with 
`ozone.s3g.health-check.enabled=false`, which removes them entirely. Note that 
the bundled Kubernetes manifests and HAProxy configs probe `/health/live` and 
`/health/ready` unconditionally, so if you disabl [...]
+
 ## Expose any volume
 
 Ozone has one more element in the name-space hierarchy compared to S3: the 
volumes. By default, all the buckets of the `/s3v` volume can be accessed with 
S3 interface but only the (Ozone) buckets of the `/s3v` volumes are exposed.
diff --git 
a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/conf/S3GatewayHealthCheckConfig.java
 
b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/conf/S3GatewayHealthCheckConfig.java
new file mode 100644
index 00000000000..3ced4a9ff53
--- /dev/null
+++ 
b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/conf/S3GatewayHealthCheckConfig.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.conf;
+
+import static org.apache.hadoop.hdds.conf.ConfigTag.MANAGEMENT;
+import static org.apache.hadoop.hdds.conf.ConfigTag.OZONE;
+import static org.apache.hadoop.hdds.conf.ConfigTag.S3GATEWAY;
+
+import java.util.concurrent.TimeUnit;
+import org.apache.hadoop.hdds.conf.Config;
+import org.apache.hadoop.hdds.conf.ConfigGroup;
+import org.apache.hadoop.hdds.conf.ConfigType;
+
+/**
+ * Config for the S3 Gateway health check endpoints served on the web admin
+ * server (default port 19878).
+ */
+@ConfigGroup(prefix = "ozone.s3g.health-check")
+public class S3GatewayHealthCheckConfig {
+
+  @Config(key = "ozone.s3g.health-check.enabled",
+      defaultValue = "true",
+      type = ConfigType.BOOLEAN,
+      tags = {OZONE, S3GATEWAY, MANAGEMENT},
+      description = "If enabled, the S3 Gateway web admin server exposes " +
+          "unauthenticated health endpoints that a load balancer can poll: " +
+          "a liveness endpoint at /health/live (process is up) and a " +
+          "readiness endpoint at /health/ready (the gateway can reach OM). " +
+          "Disable to remove the endpoints entirely; the bundled Kubernetes " +
+          "manifests and HAProxy configs probe these paths unconditionally, " +
+          "so disabling here also requires removing those probes from the " +
+          "deployment, or liveness checks will restart the pods and HAProxy " +
+          "will mark the backends down.")
+  private boolean enabled = true;
+
+  @Config(key = "ozone.s3g.health-check.probe.interval",
+      defaultValue = "10s",
+      type = ConfigType.TIME,
+      timeUnit = TimeUnit.MILLISECONDS,
+      tags = {OZONE, S3GATEWAY, MANAGEMENT},
+      description = "How often the readiness endpoint refreshes its cached " +
+          "view of OM reachability in the background. The probe runs off the " 
+
+          "request path so /health/ready always responds immediately.")
+  private long probeInterval = 10 * 1000;
+
+  @Config(key = "ozone.s3g.health-check.probe.timeout",
+      defaultValue = "10s",
+      type = ConfigType.TIME,
+      timeUnit = TimeUnit.MILLISECONDS,
+      tags = {OZONE, S3GATEWAY, MANAGEMENT},
+      description = "Upper bound on a single background readiness probe to " +
+          "OM. If a probe does not complete within this time the gateway is " +
+          "reported as not ready, so a stuck OM cannot leave readiness " +
+          "stuck at ready.")
+  private long probeTimeout = 10 * 1000;
+
+  public boolean isEnabled() {
+    return enabled;
+  }
+
+  public void setEnabled(boolean enabled) {
+    this.enabled = enabled;
+  }
+
+  public long getProbeInterval() {
+    return probeInterval;
+  }
+
+  public void setProbeInterval(long probeInterval) {
+    this.probeInterval = probeInterval;
+  }
+
+  public long getProbeTimeout() {
+    return probeTimeout;
+  }
+
+  public void setProbeTimeout(long probeTimeout) {
+    this.probeTimeout = probeTimeout;
+  }
+}
diff --git a/hadoop-ozone/dist/src/main/compose/common/s3-haproxy.cfg 
b/hadoop-ozone/dist/src/main/compose/common/s3-haproxy.cfg
index 6e8728945f2..9f82f51c7f8 100644
--- a/hadoop-ozone/dist/src/main/compose/common/s3-haproxy.cfg
+++ b/hadoop-ozone/dist/src/main/compose/common/s3-haproxy.cfg
@@ -33,9 +33,14 @@ frontend http-in
 
 backend servers
     balance roundrobin
-    server server1 s3g1:9878 maxconn 32
-    server server2 s3g2:9878 maxconn 32
-    server server3 s3g3:9878 maxconn 32
+    # Gate S3 traffic (9878) on the readiness endpoint served by the web admin
+    # server (19878): it reports ready only when the gateway can reach OM. The
+    # endpoint is unauthenticated, so no SigV4 is needed.
+    option httpchk GET /health/ready
+    http-check expect status 200
+    server server1 s3g1:9878 maxconn 32 check port 19878 inter 2s rise 1
+    server server2 s3g2:9878 maxconn 32 check port 19878 inter 2s rise 1
+    server server3 s3g3:9878 maxconn 32 check port 19878 inter 2s rise 1
 
 frontend webadmin
     bind *:19878
diff --git a/hadoop-ozone/dist/src/main/compose/ozone/test.sh 
b/hadoop-ozone/dist/src/main/compose/ozone/test.sh
index 980d1487f80..e4e27ede99d 100755
--- a/hadoop-ozone/dist/src/main/compose/ozone/test.sh
+++ b/hadoop-ozone/dist/src/main/compose/ozone/test.sh
@@ -57,6 +57,8 @@ execute_robot_test scm -v SCHEME:ofs -N ozonefs-obs 
ozonefs/ozonefs-obs.robot
 
 execute_robot_test s3g grpc/grpc-om-s3-metrics.robot
 
+execute_robot_test s3g s3/health.robot
+
 execute_robot_test scm --exclude om_filesystem --exclude 
pre-finalized-snapshot-tests snapshot
 
 # snapshot-defrag.robot reads OmSnapshot local YAML under the OM data 
directory; Robot must run in the om container.
diff --git a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/s3-haproxy.cfg 
b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/s3-haproxy.cfg
index c93d1b16b64..441486af58f 100644
--- a/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/s3-haproxy.cfg
+++ b/hadoop-ozone/dist/src/main/compose/ozonesecure-ha/s3-haproxy.cfg
@@ -33,9 +33,14 @@ frontend http-in
 
 backend servers
     balance roundrobin
-    server server1 172.25.0.121:9878 maxconn 32
-    server server2 172.25.0.122:9878 maxconn 32
-    server server3 172.25.0.123:9878 maxconn 32
+    # Gate S3 traffic (9878) on the readiness endpoint served by the web admin
+    # server (19878): it reports ready only when the gateway can reach OM. The
+    # endpoint is unauthenticated, so no SigV4 is needed.
+    option httpchk GET /health/ready
+    http-check expect status 200
+    server server1 172.25.0.121:9878 maxconn 32 check port 19878 inter 2s rise 
1
+    server server2 172.25.0.122:9878 maxconn 32 check port 19878 inter 2s rise 
1
+    server server3 172.25.0.123:9878 maxconn 32 check port 19878 inter 2s rise 
1
 
 frontend webadmin
     bind *:19878
diff --git a/hadoop-ozone/dist/src/main/compose/testlib.sh 
b/hadoop-ozone/dist/src/main/compose/testlib.sh
index c541a234b71..d9f3868bcdd 100755
--- a/hadoop-ozone/dist/src/main/compose/testlib.sh
+++ b/hadoop-ozone/dist/src/main/compose/testlib.sh
@@ -115,6 +115,18 @@ find_tests(){
 }
 
 ## @description wait until safemode exit (or 240 seconds)
+## @description wait until S3 Gateway reports ready via /health/ready (port 
19878)
+## Required when HAProxy health-checks gate S3 traffic: without a wait, tests
+## start while all backends are still in DOWN state and HAProxy returns 503.
+## Only waits when the multi-instance HAProxy setup (s3g1/s3g2/s3g3) is 
present.
+wait_for_s3g_ready() {
+  if ! docker-compose config --services 2>/dev/null | grep -q "^s3g1$"; then
+    return 0
+  fi
+  echo "Waiting for S3 Gateway (s3g1) to report ready..."
+  wait_for_execute_command s3g1 120 "curl -sf 
http://localhost:19878/health/ready";
+}
+
 wait_for_safemode_exit(){
   local cmd="ozone admin safemode wait -t 240"
   if [[ "${SECURITY_ENABLED}" == 'true' ]]; then
@@ -207,6 +219,7 @@ start_docker_env(){
 
   wait_for_safemode_exit
   wait_for_om_leader
+  wait_for_s3g_ready
 }
 
 has_scalable_datanode() {
diff --git a/hadoop-ozone/dist/src/main/k8s/definitions/ozone/s3g-ss.yaml 
b/hadoop-ozone/dist/src/main/k8s/definitions/ozone/s3g-ss.yaml
index deb9cfe7b16..2ee375a75a1 100644
--- a/hadoop-ozone/dist/src/main/k8s/definitions/ozone/s3g-ss.yaml
+++ b/hadoop-ozone/dist/src/main/k8s/definitions/ozone/s3g-ss.yaml
@@ -38,6 +38,10 @@ spec:
         args: ["ozone","s3g"]
         livenessProbe:
           httpGet:
-            path: /
+            path: /health/live
             port: 19878
           initialDelaySeconds: 30
+        readinessProbe:
+          httpGet:
+            path: /health/ready
+            port: 19878
diff --git 
a/hadoop-ozone/dist/src/main/k8s/examples/getting-started/s3g-statefulset.yaml 
b/hadoop-ozone/dist/src/main/k8s/examples/getting-started/s3g-statefulset.yaml
index ecfd85801ba..319a04f9caf 100644
--- 
a/hadoop-ozone/dist/src/main/k8s/examples/getting-started/s3g-statefulset.yaml
+++ 
b/hadoop-ozone/dist/src/main/k8s/examples/getting-started/s3g-statefulset.yaml
@@ -41,9 +41,13 @@ spec:
         - s3g
         livenessProbe:
           httpGet:
-            path: /
+            path: /health/live
             port: 19878
           initialDelaySeconds: 30
+        readinessProbe:
+          httpGet:
+            path: /health/ready
+            port: 19878
         envFrom:
         - configMapRef:
             name: config
diff --git 
a/hadoop-ozone/dist/src/main/k8s/examples/minikube/s3g-statefulset.yaml 
b/hadoop-ozone/dist/src/main/k8s/examples/minikube/s3g-statefulset.yaml
index ecfd85801ba..319a04f9caf 100644
--- a/hadoop-ozone/dist/src/main/k8s/examples/minikube/s3g-statefulset.yaml
+++ b/hadoop-ozone/dist/src/main/k8s/examples/minikube/s3g-statefulset.yaml
@@ -41,9 +41,13 @@ spec:
         - s3g
         livenessProbe:
           httpGet:
-            path: /
+            path: /health/live
             port: 19878
           initialDelaySeconds: 30
+        readinessProbe:
+          httpGet:
+            path: /health/ready
+            port: 19878
         envFrom:
         - configMapRef:
             name: config
diff --git 
a/hadoop-ozone/dist/src/main/k8s/examples/ozone-dev/s3g-statefulset.yaml 
b/hadoop-ozone/dist/src/main/k8s/examples/ozone-dev/s3g-statefulset.yaml
index 7e405922fd8..37dc42f8a97 100644
--- a/hadoop-ozone/dist/src/main/k8s/examples/ozone-dev/s3g-statefulset.yaml
+++ b/hadoop-ozone/dist/src/main/k8s/examples/ozone-dev/s3g-statefulset.yaml
@@ -41,9 +41,13 @@ spec:
         - s3g
         livenessProbe:
           httpGet:
-            path: /
+            path: /health/live
             port: 19878
           initialDelaySeconds: 30
+        readinessProbe:
+          httpGet:
+            path: /health/ready
+            port: 19878
         envFrom:
         - configMapRef:
             name: config
diff --git 
a/hadoop-ozone/dist/src/main/k8s/examples/ozone-ha/s3g-statefulset.yaml 
b/hadoop-ozone/dist/src/main/k8s/examples/ozone-ha/s3g-statefulset.yaml
index 1ed0deb3099..b7bc8f525a2 100644
--- a/hadoop-ozone/dist/src/main/k8s/examples/ozone-ha/s3g-statefulset.yaml
+++ b/hadoop-ozone/dist/src/main/k8s/examples/ozone-ha/s3g-statefulset.yaml
@@ -41,9 +41,13 @@ spec:
         - s3g
         livenessProbe:
           httpGet:
-            path: /
+            path: /health/live
             port: 19878
           initialDelaySeconds: 30
+        readinessProbe:
+          httpGet:
+            path: /health/ready
+            port: 19878
         envFrom:
         - configMapRef:
             name: config
diff --git a/hadoop-ozone/dist/src/main/k8s/examples/ozone/s3g-statefulset.yaml 
b/hadoop-ozone/dist/src/main/k8s/examples/ozone/s3g-statefulset.yaml
index 1ed0deb3099..b7bc8f525a2 100644
--- a/hadoop-ozone/dist/src/main/k8s/examples/ozone/s3g-statefulset.yaml
+++ b/hadoop-ozone/dist/src/main/k8s/examples/ozone/s3g-statefulset.yaml
@@ -41,9 +41,13 @@ spec:
         - s3g
         livenessProbe:
           httpGet:
-            path: /
+            path: /health/live
             port: 19878
           initialDelaySeconds: 30
+        readinessProbe:
+          httpGet:
+            path: /health/ready
+            port: 19878
         envFrom:
         - configMapRef:
             name: config
diff --git a/hadoop-ozone/dist/src/main/smoketest/s3/health.robot 
b/hadoop-ozone/dist/src/main/smoketest/s3/health.robot
new file mode 100644
index 00000000000..35cdb34749e
--- /dev/null
+++ b/hadoop-ozone/dist/src/main/smoketest/s3/health.robot
@@ -0,0 +1,43 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+*** Settings ***
+Documentation       S3 gateway health endpoints test
+Library             OperatingSystem
+Library             String
+Resource            ../commonlib.robot
+Test Timeout        5 minutes
+Default Tags        no-bucket-type
+
+*** Variables ***
+
+${S3G_HEALTH}       http://s3g:19878
+
+*** Keywords ***
+# The health endpoints are unauthenticated, so a plain curl (no SPNEGO) must
+# succeed even when security is enabled.
+Readiness reports ready
+    ${result} =         Execute                             curl -sS -i 
${S3G_HEALTH}/health/ready
+                        Should contain      ${result}       200
+                        Should contain      ${result}       READY
+
+*** Test Cases ***
+Liveness endpoint returns OK
+    ${result} =         Execute                             curl -sS -i 
${S3G_HEALTH}/health/live
+                        Should contain      ${result}       200
+                        Should contain      ${result}       OK
+
+Readiness endpoint reports ready when OM is reachable
+    Wait Until Keyword Succeeds     30sec       5sec        Readiness reports 
ready
diff --git 
a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/TestS3GatewayHealthCheck.java
 
b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/TestS3GatewayHealthCheck.java
new file mode 100644
index 00000000000..562daef81c4
--- /dev/null
+++ 
b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/TestS3GatewayHealthCheck.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3;
+
+import static java.net.HttpURLConnection.HTTP_OK;
+import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.IOUtils;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.MiniOzoneCluster;
+import org.apache.ozone.test.ClusterForTests;
+import org.apache.ozone.test.GenericTestUtils;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+/**
+ * Integration test for the S3 Gateway health endpoints against a live
+ * {@link MiniOzoneCluster}.  Covers both the ready path (OM reachable) and the
+ * not-ready path (OM stopped).  The OM-stop step is destructive and the 
cluster
+ * is shared across the class, so the checks are ordered.
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class TestS3GatewayHealthCheck extends ClusterForTests<MiniOzoneCluster> {
+
+  private final S3GatewayService s3g = new S3GatewayService();
+
+  @Override
+  protected OzoneConfiguration createOzoneConfig() {
+    OzoneConfiguration conf = createBaseConfiguration();
+    // Poll OM often and time out quickly so readiness flips fast in the test.
+    conf.setTimeDuration("ozone.s3g.health-check.probe.interval", 500, 
TimeUnit.MILLISECONDS);
+    conf.setTimeDuration("ozone.s3g.health-check.probe.timeout", 2, 
TimeUnit.SECONDS);
+    return conf;
+  }
+
+  @Override
+  protected MiniOzoneCluster createCluster() throws Exception {
+    return newClusterBuilder()
+        .addService(s3g)
+        .build();
+  }
+
+  @Test
+  @Order(1)
+  void livenessReturnsOk() throws Exception {
+    assertEquals(HTTP_OK, responseCode(healthUrl("/health/live")));
+    assertEquals("OK", body(healthUrl("/health/live")));
+  }
+
+  @Test
+  @Order(2)
+  void readinessReturnsReadyWhenOmReachable() throws Exception {
+    GenericTestUtils.waitFor(() -> responseCode(healthUrl("/health/ready")) == 
HTTP_OK,
+        200, 30_000);
+    assertEquals("READY", body(healthUrl("/health/ready")));
+  }
+
+  @Test
+  @Order(3)
+  void readinessReturnsNotReadyWhenOmUnreachable() throws Exception {
+    getCluster().getOzoneManager().stop();
+
+    GenericTestUtils.waitFor(() -> responseCode(healthUrl("/health/ready")) == 
HTTP_UNAVAILABLE,
+        200, 30_000);
+    assertEquals("NOT READY", body(healthUrl("/health/ready")));
+
+    // Liveness stays up regardless of OM reachability.
+    assertEquals(HTTP_OK, responseCode(healthUrl("/health/live")));
+  }
+
+  private String healthUrl(String path) {
+    String address = s3g.getConf()
+        .get(S3GatewayConfigKeys.OZONE_S3G_WEBADMIN_HTTP_ADDRESS_KEY);
+    return "http://"; + address + path;
+  }
+
+  private static int responseCode(String url) {
+    HttpURLConnection connection = null;
+    try {
+      connection = (HttpURLConnection) new URL(url).openConnection();
+      connection.setRequestMethod("GET");
+      return connection.getResponseCode();
+    } catch (Exception e) {
+      return -1;
+    } finally {
+      if (connection != null) {
+        connection.disconnect();
+      }
+    }
+  }
+
+  private static String body(String url) throws Exception {
+    HttpURLConnection connection = (HttpURLConnection) new 
URL(url).openConnection();
+    connection.setRequestMethod("GET");
+    int code = connection.getResponseCode();
+    try (InputStream in = code < 400
+        ? connection.getInputStream() : connection.getErrorStream()) {
+      return IOUtils.toString(in, StandardCharsets.UTF_8).trim();
+    } finally {
+      connection.disconnect();
+    }
+  }
+}
diff --git a/hadoop-ozone/s3gateway/pom.xml b/hadoop-ozone/s3gateway/pom.xml
index 7a27e1d4b53..007e399f929 100644
--- a/hadoop-ozone/s3gateway/pom.xml
+++ b/hadoop-ozone/s3gateway/pom.xml
@@ -171,6 +171,10 @@
       <groupId>org.apache.ratis</groupId>
       <artifactId>ratis-common</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-server</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.eclipse.jetty</groupId>
       <artifactId>jetty-servlet</artifactId>
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3GatewayReadinessProbe.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3GatewayReadinessProbe.java
new file mode 100644
index 00000000000..65b4fff043a
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3GatewayReadinessProbe.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3;
+
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.io.Closeable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.utils.IOUtils;
+import org.apache.hadoop.ozone.client.OzoneClient;
+import org.apache.hadoop.ozone.conf.S3GatewayHealthCheckConfig;
+import org.apache.hadoop.ozone.om.protocol.S3Auth;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Backs the S3 Gateway readiness endpoint (/health/ready).
+ *
+ * <p>A single background thread periodically probes OM reachability and stores
+ * the result in a volatile flag. The servlet only reads that flag, so
+ * /health/ready responds immediately (no OM RPC on the request path) and a
+ * load balancer polling it can never be blocked by a slow or unreachable OM.
+ *
+ * <p>Readiness starts as {@code false} and flips to {@code true} only after a
+ * probe succeeds, so the endpoint reports "not ready" during startup (before
+ * the first successful probe) without waiting for the first S3 request. The
+ * probe owns a dedicated {@link OzoneClient} created through
+ * {@link OzoneClientCache#createClient}, so it exercises the same OM transport
+ * the gateway serves with, independent of the lazily-created CDI client on the
+ * S3 listener.
+ */
+public class S3GatewayReadinessProbe implements Closeable {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(S3GatewayReadinessProbe.class);
+
+  private final OzoneConfiguration conf;
+  private final long intervalMillis;
+  private final long timeoutMillis;
+
+  private final ScheduledExecutorService scheduler;
+  private final ExecutorService probeExecutor;
+
+  private volatile boolean ready = false;
+  private volatile OzoneClient client;
+
+  /**
+   * The probe task submitted on the previous tick, accessed only from the
+   * single scheduler thread. Used to keep at most one probe in flight: if the
+   * task is still running (e.g. an OM call that did not unblock on
+   * interruption), the next tick skips submitting another rather than queueing
+   * work on the single probe thread.
+   */
+  private Future<Void> inFlight;
+
+  S3GatewayReadinessProbe(OzoneConfiguration conf,
+      S3GatewayHealthCheckConfig healthConfig) {
+    this.conf = conf;
+    this.intervalMillis = healthConfig.getProbeInterval();
+    this.timeoutMillis = healthConfig.getProbeTimeout();
+    this.scheduler = Executors.newSingleThreadScheduledExecutor(
+        new ThreadFactoryBuilder()
+            .setNameFormat("s3g-readiness-probe-%d")
+            .setDaemon(true)
+            .build());
+    this.probeExecutor = Executors.newSingleThreadExecutor(
+        new ThreadFactoryBuilder()
+            .setNameFormat("s3g-readiness-om-%d")
+            .setDaemon(true)
+            .build());
+  }
+
+  public void start() {
+    scheduler.scheduleWithFixedDelay(this::probe, 0, intervalMillis,
+        TimeUnit.MILLISECONDS);
+  }
+
+  public boolean isReady() {
+    return ready;
+  }
+
+  /**
+   * Runs one probe on the scheduler thread, bounded by the configured timeout.
+   * The OM call runs on a separate single-threaded executor so a hung call
+   * cannot block the scheduler and gets an enforced deadline: on timeout the
+   * gateway is reported not ready rather than staying stuck at ready. At most
+   * one probe is kept in flight, so a call that ignores interruption cannot 
pile
+   * up queued work on the probe thread during a prolonged OM outage.
+   */
+  private void probe() {
+    if (inFlight != null && !inFlight.isDone()) {
+      // The previous probe's OM call has not returned yet (e.g. it did not
+      // unblock on interruption). Report not ready and retry cancelling rather
+      // than submitting another task onto the single probe thread.
+      ready = false;
+      inFlight.cancel(true);
+      LOG.debug("Previous readiness probe still in flight; reporting not 
ready");
+      return;
+    }
+    Future<Void> future = probeExecutor.submit(() -> {
+      OzoneClient c = client;
+      if (c == null) {
+        c = OzoneClientCache.createClient(probeClientConf());
+        client = c;
+      }
+      c.getObjectStore().getClientProxy().getOzoneManagerClient()
+          .getServiceInfo();
+      return null;
+    });
+    inFlight = future;
+    try {
+      future.get(timeoutMillis, TimeUnit.MILLISECONDS);
+      ready = true;
+    } catch (TimeoutException e) {
+      ready = false;
+      future.cancel(true);
+      LOG.debug("Readiness probe timed out after {} ms", timeoutMillis);
+    } catch (Exception e) {
+      ready = false;
+      LOG.debug("Readiness probe failed; reporting not ready", e);
+    }
+  }
+
+  /**
+   * A copy of the gateway config tuned for probing: S3 auth is disabled
+   * (getServiceInfo does not need S3 credentials) and the OM RPC timeout is
+   * bounded so a stuck probe thread eventually unblocks.
+   */
+  private OzoneConfiguration probeClientConf() {
+    OzoneConfiguration probeConf = new OzoneConfiguration(conf);
+    probeConf.setBoolean(S3Auth.S3_AUTH_CHECK, false);
+    probeConf.setBoolean("ipc.client.ping", false);
+    probeConf.setTimeDuration("ozone.om.client.rpc.timeout", timeoutMillis,
+        TimeUnit.MILLISECONDS);
+    return probeConf;
+  }
+
+  @Override
+  public void close() {
+    scheduler.shutdownNow();
+    probeExecutor.shutdownNow();
+    IOUtils.close(LOG, client);
+  }
+}
diff --git 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3GatewayWebAdminServer.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3GatewayWebAdminServer.java
index f87343cfb92..d60228d87fc 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3GatewayWebAdminServer.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/S3GatewayWebAdminServer.java
@@ -43,8 +43,10 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.hadoop.hdds.conf.MutableConfigurationSource;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.apache.hadoop.hdds.server.http.BaseHttpServer;
 import org.apache.hadoop.hdds.server.http.ServletElementsFactory;
+import org.apache.hadoop.ozone.conf.S3GatewayHealthCheckConfig;
 import org.apache.hadoop.security.SecurityUtil;
 import org.apache.hadoop.security.UserGroupInformation;
 import org.apache.hadoop.security.authentication.server.AuthenticationFilter;
@@ -62,12 +64,48 @@ class S3GatewayWebAdminServer extends BaseHttpServer {
   private static final Logger LOG =
       LoggerFactory.getLogger(S3GatewayWebAdminServer.class);
 
+  static final String READINESS_PROBE_ATTRIBUTE =
+      "ozone.s3g.readiness.probe";
+
+  private final S3GatewayReadinessProbe readinessProbe;
+
   S3GatewayWebAdminServer(MutableConfigurationSource conf, String name) throws 
IOException {
     super(conf, name);
     addServlet("icon", "/favicon.ico", IconServlet.class);
+    S3GatewayHealthCheckConfig healthConfig =
+        conf.getObject(S3GatewayHealthCheckConfig.class);
+    if (healthConfig.isEnabled()) {
+      // Register via addInternalServlet so no authentication filter is mapped
+      // to the paths: the endpoints must be reachable by a load balancer
+      // without Kerberos/SPNEGO, including in secure mode.
+      addInternalServlet("liveness", "/health/live", LivenessServlet.class);
+      readinessProbe =
+          new S3GatewayReadinessProbe(OzoneConfiguration.of(conf), 
healthConfig);
+      getWebAppContext().getServletContext()
+          .setAttribute(READINESS_PROBE_ATTRIBUTE, readinessProbe);
+      addInternalServlet("readiness", "/health/ready", ReadinessServlet.class);
+    } else {
+      readinessProbe = null;
+    }
     addSecretAuthentication(conf);
   }
 
+  @Override
+  public void start() throws IOException {
+    super.start();
+    if (readinessProbe != null) {
+      readinessProbe.start();
+    }
+  }
+
+  @Override
+  public void stop() throws Exception {
+    if (readinessProbe != null) {
+      readinessProbe.close();
+    }
+    super.stop();
+  }
+
   private void addSecretAuthentication(MutableConfigurationSource conf)
       throws IOException {
 
@@ -183,4 +221,47 @@ public void doGet(HttpServletRequest request, 
HttpServletResponse response)
       response.sendRedirect("/images/ozone.ico");
     }
   }
+
+  /**
+   * Liveness probe: returns 200 as long as the gateway process is up and its
+   * web admin server can serve requests. It does not check OM reachability;
+   * that is the responsibility of the readiness endpoint.
+   */
+  public static class LivenessServlet extends HttpServlet {
+    private static final long serialVersionUID = -1L;
+
+    @Override
+    public void doGet(HttpServletRequest request, HttpServletResponse response)
+        throws IOException {
+      response.setStatus(HttpServletResponse.SC_OK);
+      response.setContentType("text/plain; charset=utf-8");
+      response.getWriter().println("OK");
+    }
+  }
+
+  /**
+   * Readiness probe: returns 200 only when a background probe has confirmed 
the
+   * gateway can reach OM, and 503 otherwise (during startup or while OM is
+   * unreachable). The check reads a cached flag maintained off the request
+   * path, so the endpoint always responds immediately and a load balancer
+   * polling it cannot be blocked by a slow OM.
+   */
+  public static class ReadinessServlet extends HttpServlet {
+    private static final long serialVersionUID = -1L;
+
+    @Override
+    public void doGet(HttpServletRequest request, HttpServletResponse response)
+        throws IOException {
+      S3GatewayReadinessProbe probe = (S3GatewayReadinessProbe)
+          getServletContext().getAttribute(READINESS_PROBE_ATTRIBUTE);
+      response.setContentType("text/plain; charset=utf-8");
+      if (probe != null && probe.isReady()) {
+        response.setStatus(HttpServletResponse.SC_OK);
+        response.getWriter().println("READY");
+      } else {
+        response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+        response.getWriter().println("NOT READY");
+      }
+    }
+  }
 }
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestS3GatewayLivenessServlet.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestS3GatewayLivenessServlet.java
new file mode 100644
index 00000000000..4add70246b3
--- /dev/null
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestS3GatewayLivenessServlet.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.conf.S3GatewayHealthCheckConfig;
+import org.apache.hadoop.ozone.s3.S3GatewayWebAdminServer.LivenessServlet;
+import org.apache.hadoop.ozone.s3.S3GatewayWebAdminServer.ReadinessServlet;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for the S3 Gateway health endpoints and their config toggle.
+ */
+public class TestS3GatewayLivenessServlet {
+
+  @Test
+  public void livenessReturnsOk() throws Exception {
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    StringWriter body = new StringWriter();
+    when(response.getWriter()).thenReturn(new PrintWriter(body));
+
+    new LivenessServlet().doGet(request, response);
+
+    verify(response).setStatus(HttpServletResponse.SC_OK);
+    assertEquals("OK", body.toString().trim());
+  }
+
+  @Test
+  public void readinessReturnsOkWhenReady() throws Exception {
+    StringWriter body = new StringWriter();
+    HttpServletResponse response = invokeReadiness(readyProbe(true), body);
+    verify(response).setStatus(HttpServletResponse.SC_OK);
+    assertEquals("READY", body.toString().trim());
+  }
+
+  @Test
+  public void readinessReturns503WhenNotReady() throws Exception {
+    StringWriter body = new StringWriter();
+    HttpServletResponse response = invokeReadiness(readyProbe(false), body);
+    verify(response).setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+    assertEquals("NOT READY", body.toString().trim());
+  }
+
+  @Test
+  public void readinessReturns503WhenProbeMissing() throws Exception {
+    HttpServletResponse response = invokeReadiness(null, new StringWriter());
+    verify(response).setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+  }
+
+  @Test
+  public void readinessStartsNotReady() {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    S3GatewayReadinessProbe probe = new S3GatewayReadinessProbe(conf,
+        conf.getObject(S3GatewayHealthCheckConfig.class));
+    try {
+      assertFalse(probe.isReady());
+    } finally {
+      probe.close();
+    }
+  }
+
+  @Test
+  public void healthCheckEnabledByDefault() {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    assertTrue(conf.getObject(S3GatewayHealthCheckConfig.class).isEnabled());
+  }
+
+  @Test
+  public void healthCheckCanBeDisabled() {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    conf.setBoolean("ozone.s3g.health-check.enabled", false);
+    assertFalse(conf.getObject(S3GatewayHealthCheckConfig.class).isEnabled());
+  }
+
+  private static S3GatewayReadinessProbe readyProbe(boolean ready) {
+    S3GatewayReadinessProbe probe = mock(S3GatewayReadinessProbe.class);
+    when(probe.isReady()).thenReturn(ready);
+    return probe;
+  }
+
+  private static HttpServletResponse invokeReadiness(
+      S3GatewayReadinessProbe probe, StringWriter body) throws Exception {
+    ReadinessServlet servlet = spy(new ReadinessServlet());
+    ServletContext context = mock(ServletContext.class);
+    
when(context.getAttribute(S3GatewayWebAdminServer.READINESS_PROBE_ATTRIBUTE))
+        .thenReturn(probe);
+    doReturn(context).when(servlet).getServletContext();
+
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    HttpServletResponse response = mock(HttpServletResponse.class);
+    when(response.getWriter()).thenReturn(new PrintWriter(body));
+
+    servlet.doGet(request, response);
+    return response;
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to