You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by zh...@apache.org on 2026/08/08 13:37:28 UTC

(dubbo-kubernetes) branch master updated: Complete production service activation path (#1006)

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

mfordjody pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/dubbo-kubernetes.git


The following commit(s) were added to refs/heads/master by this push:
     new 0226285e Complete production service activation path (#1006)
0226285e is described below

commit 0226285ea46613234bdfee4c4ea8e3c3a73f9736
Author: mfordjody <11...@qq.com>
AuthorDate: Sat Aug 8 21:37:24 2026 +0800

    Complete production service activation path (#1006)
    
    * feat: complete production service activation
    
    * test: capture managed gateway crash logs
    
    * ci: run activation e2e natively on arm64
---
 .github/workflows/ci.yml                           |  50 ++++-
 dubbod/discovery/cmd/app/grpc_outbound.go          |   7 +
 dubbod/discovery/cmd/app/grpc_outbound_test.go     |  34 ++++
 .../discovery/pkg/activation/cluster_readiness.go  | 140 ++++++++++++++
 .../pkg/activation/cluster_readiness_test.go       |  62 +++++++
 dubbod/discovery/pkg/activation/controller.go      |  78 ++++++--
 dubbod/discovery/pkg/activation/policy.go          |  41 ++---
 dubbod/discovery/pkg/activation/policy_test.go     |  42 +++--
 dubbod/discovery/pkg/bootstrap/activation.go       |   2 +-
 dubbod/discovery/pkg/bootstrap/server.go           |  22 ++-
 .../config/kube/gateway/deployment_controller.go   |  76 ++++++--
 .../kube/gateway/deployment_controller_test.go     |  32 +++-
 dubbod/discovery/pkg/model/push_context.go         | 128 +++++++++----
 dubbod/discovery/pkg/model/push_context_test.go    |  49 +++++
 dubbod/discovery/pkg/networking/grpcgen/rds.go     |  36 ++--
 .../discovery/pkg/networking/grpcgen/rds_test.go   |  39 ++++
 dubbod/discovery/pkg/xds/delta_test.go             |   6 +
 dubbod/discovery/pkg/xds/xdsgen.go                 |   5 +-
 manifests/charts/dubbod/templates/clusterrole.yaml |   6 +
 manifests/charts/dubbod/templates/deployment.yaml  |   3 +
 manifests/charts/dubbod/values.yaml                |   4 +
 samples/activation/README.md                       |  30 +--
 samples/activation/activation-policy.yaml          |   6 +-
 samples/activation/scaledobject.yaml               |   8 +-
 tests/e2e/run.sh                                   | 203 ++++++++++++++++++++-
 .../testdata/eastwest-activation-scaledobject.yaml |  39 ++++
 26 files changed, 993 insertions(+), 155 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c0344dc3..79b5fdb0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -228,6 +228,51 @@ jobs:
           UPGRADE_FROM_VERSION: '0.4.3'
         run: make test-e2e
 
+  activation-e2e:
+    name: Activation E2E (KEDA)
+    runs-on: ubuntu-24.04-arm
+    timeout-minutes: 45
+    if: github.repository == 'apache/dubbo-kubernetes'
+    env:
+      DOCKER_DEFAULT_PLATFORM: linux/arm64
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Verify native ARM64 runner
+        run: |
+          test "$(uname -m)" = "aarch64"
+          test "$(docker info --format '{{.Architecture}}')" = "aarch64"
+
+      # Pin the data plane source that implements request holding and demand
+      # reporting. A floating main branch could make a control-plane PR pass
+      # against behavior it did not declare.
+      - name: Checkout dxgate
+        uses: actions/checkout@v4
+        with:
+          repository: kdubbo/dxgate
+          ref: d683797f44c9ba4c6e1eb6cdb2da21460b00cbfd
+          path: .e2e/dxgate
+
+      - name: Build dxgate activation image
+        run: docker build -t kdubbo/dxgate:ga-e2e .e2e/dxgate
+
+      - name: Install Helm
+        run: tools/ci/install-helm.sh v3.16.4
+
+      - name: Install kind
+        run: tools/ci/install-kind.sh v0.30.0
+
+      - name: Run real KEDA activation
+        env:
+          ACTIVATION_E2E: '1'
+          CLUSTER_NAME: dubbo-activation-ga
+          DXGATE_IMAGE: kdubbo/dxgate:ga-e2e
+          IMAGE: kdubbo/dubbod:activation-ga-e2e
+          UPGRADE_FROM_IMAGE: kdubbo/dubbod:activation-ga-e2e
+          UPGRADE_FROM_VERSION: '0.4.3'
+        run: make test-e2e
+
   required:
     name: CI Required
     runs-on: ubuntu-slim
@@ -243,6 +288,7 @@ jobs:
       - build
       - helm
       - e2e
+      - activation-e2e
     steps:
       - name: Verify required jobs passed
         env:
@@ -253,6 +299,7 @@ jobs:
           BUILD_RESULT: ${{ needs.build.result }}
           HELM_RESULT: ${{ needs.helm.result }}
           E2E_RESULT: ${{ needs.e2e.result }}
+          ACTIVATION_E2E_RESULT: ${{ needs.activation-e2e.result }}
         run: |
           failed=0
           for job_result in \
@@ -262,7 +309,8 @@ jobs:
             "performance=${PERFORMANCE_RESULT}" \
             "build=${BUILD_RESULT}" \
             "helm=${HELM_RESULT}" \
-            "e2e=${E2E_RESULT}"; do
+            "e2e=${E2E_RESULT}" \
+            "activation-e2e=${ACTIVATION_E2E_RESULT}"; do
             job="${job_result%%=*}"
             result="${job_result#*=}"
             echo "${job}: ${result}"
diff --git a/dubbod/discovery/cmd/app/grpc_outbound.go b/dubbod/discovery/cmd/app/grpc_outbound.go
index 57e191c9..6f1bde45 100644
--- a/dubbod/discovery/cmd/app/grpc_outbound.go
+++ b/dubbod/discovery/cmd/app/grpc_outbound.go
@@ -1011,6 +1011,13 @@ func runSampleRequest(ctx context.Context, adsClient *sampleADSClient, clients *
 			}
 			continue
 		}
+		if resp.StatusCode >= http.StatusBadRequest {
+			return "", fmt.Errorf(
+				"upstream returned HTTP status %d: %s",
+				resp.StatusCode,
+				strings.TrimSpace(string(body)),
+			)
+		}
 		return strings.TrimSpace(string(body)), nil
 	}
 	return "", lastErr
diff --git a/dubbod/discovery/cmd/app/grpc_outbound_test.go b/dubbod/discovery/cmd/app/grpc_outbound_test.go
index 2337413c..a871cdf9 100644
--- a/dubbod/discovery/cmd/app/grpc_outbound_test.go
+++ b/dubbod/discovery/cmd/app/grpc_outbound_test.go
@@ -351,6 +351,40 @@ func TestRunSampleRequestsRetriesConfiguredStatus(t *testing.T) {
 	}
 }
 
+func TestRunSampleRequestsRejectsUnsuccessfulStatus(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		http.Error(w, "route not found", http.StatusNotFound)
+	}))
+	defer server.Close()
+
+	endpoint := endpointForServer(t, server)
+	cluster := "outbound|8080||payment.e2e.svc.cluster.local"
+	snapshot := xdsRouteSnapshot{
+		Host: "payment.e2e.svc.cluster.local",
+		Port: 8080,
+		Destinations: []xdsDestination{{
+			Cluster:   cluster,
+			Host:      "payment.e2e.svc.cluster.local",
+			Weight:    100,
+			Endpoints: []xdsEndpoint{endpoint},
+		}},
+	}
+	client := &sampleADSClient{
+		host:           snapshot.Host,
+		port:           snapshot.Port,
+		path:           "/health",
+		route:          map[string]uint32{cluster: 100},
+		endpoints:      map[string][]xdsEndpoint{cluster: {endpoint}},
+		clusterTLS:     map[string]*tlsv1.UpstreamTlsContext{},
+		requestHeaders: http.Header{},
+	}
+
+	_, err := runSampleRequestsWithOutput(context.Background(), client, snapshot, 1, 0, time.Second, nil)
+	if err == nil || err.Error() != "upstream returned HTTP status 404: route not found" {
+		t.Fatalf("runSampleRequestsWithOutput() error = %v", err)
+	}
+}
+
 func TestRunSampleRequestsRetriesConnectionFailureOnNextEndpoint(t *testing.T) {
 	goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 		_, _ = w.Write([]byte("recovered"))
diff --git a/dubbod/discovery/pkg/activation/cluster_readiness.go b/dubbod/discovery/pkg/activation/cluster_readiness.go
new file mode 100644
index 00000000..fd74ab03
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/cluster_readiness.go
@@ -0,0 +1,140 @@
+// 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 activation
+
+import (
+	"strings"
+
+	"github.com/apache/dubbo-kubernetes/dubbod/discovery/pkg/features"
+	"github.com/apache/dubbo-kubernetes/pkg/config/schema/gvr"
+	"github.com/apache/dubbo-kubernetes/pkg/kube"
+	"github.com/apache/dubbo-kubernetes/pkg/kube/controllers"
+	"github.com/apache/dubbo-kubernetes/pkg/kube/kclient"
+	"github.com/apache/dubbo-kubernetes/pkg/kube/kubetypes"
+	clientnetworking "github.com/kdubbo/client-go/pkg/apis/networking/v1alpha3"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	klabels "k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+)
+
+var scaledObjectGVR = schema.GroupVersionResource{
+	Group: "keda.sh", Version: "v1alpha1", Resource: "scaledobjects",
+}
+
+// clusterReadiness derives policy status from objects all dubbod replicas see.
+// Process-local streams are deliberately not used: in HA, KEDA and gateways
+// can connect to different replicas, so no replica has a complete view.
+type clusterReadiness struct {
+	scaledObjects  kclient.Informer[controllers.Object]
+	gateways       kclient.Informer[*gatewayv1.Gateway]
+	gatewayClasses kclient.Informer[*gatewayv1.GatewayClass]
+}
+
+func newClusterReadiness(client kube.Client) *clusterReadiness {
+	filter := kclient.Filter{ObjectFilter: client.ObjectFilter()}
+	return &clusterReadiness{
+		scaledObjects: kclient.NewDelayedInformer[controllers.Object](
+			client, scaledObjectGVR, kubetypes.DynamicInformer, filter,
+		),
+		gateways: kclient.NewDelayedInformer[*gatewayv1.Gateway](
+			client, gvr.KubernetesGateway, kubetypes.StandardInformer, filter,
+		),
+		gatewayClasses: kclient.NewDelayedInformer[*gatewayv1.GatewayClass](
+			client, gvr.GatewayClass, kubetypes.StandardInformer, filter,
+		),
+	}
+}
+
+func (r *clusterReadiness) AddEventHandlers(
+	scaledObjectChanged func(namespace, name string),
+	gatewayChanged func(namespace string),
+	gatewayClassChanged func(name string),
+) {
+	r.scaledObjects.AddEventHandler(controllers.ObjectHandler(func(object controllers.Object) {
+		scaledObjectChanged(object.GetNamespace(), object.GetName())
+	}))
+	r.gateways.AddEventHandler(controllers.ObjectHandler(func(object controllers.Object) {
+		gatewayChanged(object.GetNamespace())
+	}))
+	r.gatewayClasses.AddEventHandler(controllers.ObjectHandler(func(object controllers.Object) {
+		gatewayClassChanged(object.GetName())
+	}))
+}
+
+func (r *clusterReadiness) HasSynced() bool {
+	return r.scaledObjects.HasSynced() &&
+		r.gateways.HasSynced() &&
+		r.gatewayClasses.HasSynced()
+}
+
+func (r *clusterReadiness) ShutdownHandlers() {
+	controllers.ShutdownAll(r.scaledObjects, r.gateways, r.gatewayClasses)
+}
+
+func (r *clusterReadiness) ScalerReady(policy *clientnetworking.ServiceActivationPolicy) bool {
+	ref := policy.Spec.GetAutoscalerRef()
+	if ref == nil ||
+		!strings.EqualFold(ref.GetGroup(), scaledObjectGVR.Group) ||
+		!strings.EqualFold(ref.GetKind(), "ScaledObject") {
+		return false
+	}
+	object := r.scaledObjects.Get(ref.GetName(), policy.GetNamespace())
+	return scaledObjectReady(object)
+}
+
+func scaledObjectReady(object controllers.Object) bool {
+	scaledObject, ok := object.(*unstructured.Unstructured)
+	if !ok || scaledObject == nil {
+		return false
+	}
+	conditions, found, err := unstructured.NestedSlice(scaledObject.Object, "status", "conditions")
+	if err != nil || !found {
+		return false
+	}
+	for _, item := range conditions {
+		condition, ok := item.(map[string]any)
+		if ok && condition["type"] == "Ready" && condition["status"] == conditionTrue {
+			return true
+		}
+	}
+	return false
+}
+
+func (r *clusterReadiness) ActivatorReady(policy *clientnetworking.ServiceActivationPolicy) bool {
+	for _, gateway := range r.gateways.List(policy.GetNamespace(), klabels.Everything()) {
+		class := r.gatewayClasses.Get(string(gateway.Spec.GatewayClassName), "")
+		if class == nil || string(class.Spec.ControllerName) != features.ManagedGatewayController {
+			continue
+		}
+		if gatewayProgrammed(gateway) {
+			return true
+		}
+	}
+	return false
+}
+
+func gatewayProgrammed(gateway *gatewayv1.Gateway) bool {
+	for _, condition := range gateway.Status.Conditions {
+		if condition.Type == string(gatewayv1.GatewayConditionProgrammed) &&
+			condition.Status == metav1.ConditionTrue &&
+			condition.ObservedGeneration == gateway.Generation {
+			return true
+		}
+	}
+	return false
+}
diff --git a/dubbod/discovery/pkg/activation/cluster_readiness_test.go b/dubbod/discovery/pkg/activation/cluster_readiness_test.go
new file mode 100644
index 00000000..c72982b7
--- /dev/null
+++ b/dubbod/discovery/pkg/activation/cluster_readiness_test.go
@@ -0,0 +1,62 @@
+// 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 activation
+
+import (
+	"testing"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+)
+
+func TestScaledObjectReady(t *testing.T) {
+	object := &unstructured.Unstructured{Object: map[string]any{
+		"status": map[string]any{
+			"conditions": []any{
+				map[string]any{"type": "Ready", "status": "True"},
+			},
+		},
+	}}
+	if !scaledObjectReady(object) {
+		t.Fatal("ScaledObject Ready=True was not recognized")
+	}
+
+	object.Object["status"] = map[string]any{
+		"conditions": []any{map[string]any{"type": "Ready", "status": "False"}},
+	}
+	if scaledObjectReady(object) {
+		t.Fatal("ScaledObject Ready=False was reported ready")
+	}
+}
+
+func TestGatewayProgrammedRequiresCurrentGeneration(t *testing.T) {
+	gateway := &gatewayv1.Gateway{
+		ObjectMeta: metav1.ObjectMeta{Generation: 4},
+		Status: gatewayv1.GatewayStatus{Conditions: []metav1.Condition{{
+			Type:               string(gatewayv1.GatewayConditionProgrammed),
+			Status:             metav1.ConditionTrue,
+			ObservedGeneration: 3,
+		}}},
+	}
+	if gatewayProgrammed(gateway) {
+		t.Fatal("stale Programmed=True condition was reported ready")
+	}
+	gateway.Status.Conditions[0].ObservedGeneration = 4
+	if !gatewayProgrammed(gateway) {
+		t.Fatal("current Programmed=True condition was not recognized")
+	}
+}
diff --git a/dubbod/discovery/pkg/activation/controller.go b/dubbod/discovery/pkg/activation/controller.go
index 1a4bb324..6eb5c364 100644
--- a/dubbod/discovery/pkg/activation/controller.go
+++ b/dubbod/discovery/pkg/activation/controller.go
@@ -16,6 +16,7 @@
 package activation
 
 import (
+	"strings"
 	"time"
 
 	"github.com/apache/dubbo-kubernetes/pkg/kube"
@@ -32,9 +33,8 @@ import (
 var logger = log.RegisterScope("activation", "Service activation policies")
 
 // resyncInterval re-evaluates every policy periodically. Two of the four
-// conditions are derived from live state that produces no Kubernetes event at
-// all — a KEDA stream opening, a gateway starting or stopping reports — so
-// without a tick they would stay stale until the policy itself changed.
+// conditions are also re-evaluated periodically as a safety net for missed
+// informer edges.
 const resyncInterval = 30 * time.Second
 
 // Controller keeps ServiceActivationPolicy status in step with what the mesh
@@ -49,25 +49,43 @@ type Controller struct {
 	queue    controllers.Queue
 
 	evaluator PolicyEvaluator
+	readiness *clusterReadiness
 }
 
-// NewController wires the policy watch to the live scaler and gateway state.
-func NewController(client kube.Client, streams StreamLookup, reporters ReporterLookup) *Controller {
+// NewController wires policy status to cluster-visible autoscaler and Gateway
+// state, so every HA replica evaluates the same facts.
+func NewController(client kube.Client) *Controller {
+	readiness := newClusterReadiness(client)
 	c := &Controller{
-		policies: kclient.New[*clientnetworking.ServiceActivationPolicy](client),
-		services: kclient.New[*corev1.Service](client),
+		policies:  kclient.New[*clientnetworking.ServiceActivationPolicy](client),
+		services:  kclient.New[*corev1.Service](client),
+		readiness: readiness,
 	}
 	c.evaluator = PolicyEvaluator{
 		Services:  c,
-		Streams:   streams,
-		Reporters: reporters,
+		Scaler:    readiness,
+		Activator: readiness,
 	}
 
 	c.queue = controllers.NewQueue("service activation policy",
 		controllers.WithReconciler(c.Reconcile),
 		controllers.WithMaxAttempts(5))
 
-	c.policies.AddEventHandler(controllers.ObjectHandler(c.queue.AddObject))
+	c.policies.AddEventHandler(controllers.EventHandler[*clientnetworking.ServiceActivationPolicy]{
+		AddFunc: func(policy *clientnetworking.ServiceActivationPolicy) {
+			c.queue.AddObject(policy)
+		},
+		UpdateFunc: func(oldPolicy, newPolicy *clientnetworking.ServiceActivationPolicy) {
+			// Do not feed our status writes straight back into the queue.
+			// ScaledObject and Gateway informers drive runtime convergence.
+			if oldPolicy.GetGeneration() != newPolicy.GetGeneration() {
+				c.queue.AddObject(newPolicy)
+			}
+		},
+		DeleteFunc: func(policy *clientnetworking.ServiceActivationPolicy) {
+			c.queue.AddObject(policy)
+		},
+	})
 	// A policy is only accepted once its target Service exists, so a Service
 	// appearing later has to re-open the policies that were rejected for it.
 	c.services.AddEventHandler(controllers.ObjectHandler(func(o controllers.Object) {
@@ -75,6 +93,37 @@ func NewController(client kube.Client, streams StreamLookup, reporters ReporterL
 			c.queue.AddObject(policy)
 		}
 	}))
+	readiness.AddEventHandlers(
+		func(namespace, name string) {
+			for _, policy := range c.policies.List(namespace, klabels.Everything()) {
+				ref := policy.Spec.GetAutoscalerRef()
+				if ref != nil &&
+					ref.GetName() == name &&
+					strings.EqualFold(ref.GetGroup(), scaledObjectGVR.Group) &&
+					strings.EqualFold(ref.GetKind(), "ScaledObject") {
+					c.queue.AddObject(policy)
+				}
+			}
+		},
+		func(namespace string) {
+			for _, policy := range c.policies.List(namespace, klabels.Everything()) {
+				c.queue.AddObject(policy)
+			}
+		},
+		func(className string) {
+			namespaces := map[string]struct{}{}
+			for _, gateway := range readiness.gateways.List(metav1.NamespaceAll, klabels.Everything()) {
+				if string(gateway.Spec.GatewayClassName) == className {
+					namespaces[gateway.GetNamespace()] = struct{}{}
+				}
+			}
+			for namespace := range namespaces {
+				for _, policy := range c.policies.List(namespace, klabels.Everything()) {
+					c.queue.AddObject(policy)
+				}
+			}
+		},
+	)
 
 	return c
 }
@@ -85,12 +134,19 @@ func (c *Controller) HasService(namespace, name string) bool {
 }
 
 func (c *Controller) Run(stop <-chan struct{}) {
-	kube.WaitForCacheSync("activation controller", stop, c.policies.HasSynced, c.services.HasSynced)
+	kube.WaitForCacheSync(
+		"activation controller",
+		stop,
+		c.policies.HasSynced,
+		c.services.HasSynced,
+		c.readiness.HasSynced,
+	)
 
 	go c.resync(stop)
 
 	c.queue.Run(stop)
 	controllers.ShutdownAll(c.policies, c.services)
+	c.readiness.ShutdownHandlers()
 }
 
 // resync re-queues every policy on a tick, picking up scaler and gateway
diff --git a/dubbod/discovery/pkg/activation/policy.go b/dubbod/discovery/pkg/activation/policy.go
index 5c2483c1..162074dc 100644
--- a/dubbod/discovery/pkg/activation/policy.go
+++ b/dubbod/discovery/pkg/activation/policy.go
@@ -37,12 +37,12 @@ const (
 	// and replayed at all.
 	ConditionEligible = "Eligible"
 
-	// ConditionScalerReady covers KEDA: whether it is listening for activation
-	// on this target.
+	// ConditionScalerReady covers KEDA: whether the referenced ScaledObject is
+	// ready to obtain activation metrics.
 	ConditionScalerReady = "ScalerReady"
 
-	// ConditionActivatorReady covers the gateways: whether any of them is in a
-	// position to catch a request for this target.
+	// ConditionActivatorReady covers the data plane: whether a managed Gateway
+	// in the target namespace is programmed to catch requests.
 	ConditionActivatorReady = "ActivatorReady"
 )
 
@@ -57,23 +57,23 @@ type ServiceLookup interface {
 	HasService(namespace, name string) bool
 }
 
-// StreamLookup reports whether KEDA holds an activation stream for a target.
-type StreamLookup interface {
-	Subscribed(Target) bool
+// ScalerStatusLookup reports whether the policy's autoscaler is ready.
+type ScalerStatusLookup interface {
+	ScalerReady(*clientnetworking.ServiceActivationPolicy) bool
 }
 
-// ReporterLookup reports how many gateways stand ready to hold requests for a
-// target.
-type ReporterLookup interface {
-	Reporters(Target) int
+// ActivatorStatusLookup reports whether a managed gateway is programmed for
+// the policy's namespace.
+type ActivatorStatusLookup interface {
+	ActivatorReady(*clientnetworking.ServiceActivationPolicy) bool
 }
 
 // PolicyEvaluator turns a policy plus live state into the conditions published
 // on its status.
 type PolicyEvaluator struct {
 	Services  ServiceLookup
-	Streams   StreamLookup
-	Reporters ReporterLookup
+	Scaler    ScalerStatusLookup
+	Activator ActivatorStatusLookup
 }
 
 // Evaluate returns the conditions for one policy, in a stable order so an
@@ -99,15 +99,14 @@ func (e PolicyEvaluator) Evaluate(policy *clientnetworking.ServiceActivationPoli
 		return conditions
 	}
 
-	target := targetOf(policy)
 	eligible, eligibleReason := eligible(spec)
 	conditions = append(conditions, condition(ConditionEligible, eligible, eligibleReason, generation))
 
-	scalerReady := e.Streams != nil && e.Streams.Subscribed(target)
+	scalerReady := e.Scaler != nil && e.Scaler.ScalerReady(policy)
 	conditions = append(conditions,
 		condition(ConditionScalerReady, scalerReady, scalerReason(scalerReady), generation))
 
-	activatorReady := e.Reporters != nil && e.Reporters.Reporters(target) > 0
+	activatorReady := e.Activator != nil && e.Activator.ActivatorReady(policy)
 	conditions = append(conditions,
 		condition(ConditionActivatorReady, activatorReady, activatorReason(activatorReady), generation))
 
@@ -154,18 +153,16 @@ func eligible(spec *networking.ServiceActivationPolicy) (bool, string) {
 
 func scalerReason(ready bool) string {
 	if ready {
-		return "ScalerSubscribed"
+		return "ScaledObjectReady"
 	}
-	// The usual cause is a ScaledObject that does not point its external
-	// trigger at this scaler, or points it at a different Service.
-	return "ScalerNotSubscribed"
+	return "ScaledObjectNotReady"
 }
 
 func activatorReason(ready bool) string {
 	if ready {
-		return "GatewayReporting"
+		return "GatewayProgrammed"
 	}
-	return "NoGatewayReporting"
+	return "NoProgrammedGateway"
 }
 
 // targetOf resolves the Service a policy activates. The namespace comes from
diff --git a/dubbod/discovery/pkg/activation/policy_test.go b/dubbod/discovery/pkg/activation/policy_test.go
index ab8ede0d..77d118bf 100644
--- a/dubbod/discovery/pkg/activation/policy_test.go
+++ b/dubbod/discovery/pkg/activation/policy_test.go
@@ -29,13 +29,15 @@ type services map[string]bool
 
 func (s services) HasService(namespace, name string) bool { return s[namespace+"/"+name] }
 
-type subscribed map[Target]bool
+type scalerStatus bool
 
-func (s subscribed) Subscribed(target Target) bool { return s[target] }
+func (s scalerStatus) ScalerReady(*clientnetworking.ServiceActivationPolicy) bool { return bool(s) }
 
-type reporters map[Target]int
+type activatorStatus bool
 
-func (r reporters) Reporters(target Target) int { return r[target] }
+func (a activatorStatus) ActivatorReady(*clientnetworking.ServiceActivationPolicy) bool {
+	return bool(a)
+}
 
 // policy builds the kube wrapper from its parts. The generated spec is a proto
 // message with an embedded mutex, so it is assembled in place rather than
@@ -83,8 +85,8 @@ func conditionsByType(t *testing.T, evaluator PolicyEvaluator, p *clientnetworki
 func TestEvaluateReportsEveryConditionTrueWhenThePathIsComplete(t *testing.T) {
 	evaluator := PolicyEvaluator{
 		Services:  services{"app/orders": true},
-		Streams:   subscribed{orders: true},
-		Reporters: reporters{orders: 2},
+		Scaler:    scalerStatus(true),
+		Activator: activatorStatus(true),
 	}
 
 	got := conditionsByType(t, evaluator, validPolicy())
@@ -164,8 +166,8 @@ func TestEvaluateReportsMissingTargetService(t *testing.T) {
 func TestEvaluateRejectsProtocolsThatCannotBeHeld(t *testing.T) {
 	evaluator := PolicyEvaluator{
 		Services:  services{"app/orders": true},
-		Streams:   subscribed{orders: true},
-		Reporters: reporters{orders: 1},
+		Scaler:    scalerStatus(true),
+		Activator: activatorStatus(true),
 	}
 
 	unknown := policy(serviceTarget("orders"), autoscaler("orders"), networking.ActivationProtocol(99))
@@ -198,27 +200,27 @@ func TestEvaluateAcceptsEveryActivatableProtocol(t *testing.T) {
 func TestEvaluateReportsScalerAndActivatorSeparately(t *testing.T) {
 	evaluator := PolicyEvaluator{
 		Services:  services{"app/orders": true},
-		Streams:   subscribed{},
-		Reporters: reporters{orders: 1},
+		Scaler:    scalerStatus(false),
+		Activator: activatorStatus(true),
 	}
 	got := conditionsByType(t, evaluator, validPolicy())
-	if want := "False/ScalerNotSubscribed"; got[ConditionScalerReady] != want {
+	if want := "False/ScaledObjectNotReady"; got[ConditionScalerReady] != want {
 		t.Fatalf("ScalerReady = %q, want %q", got[ConditionScalerReady], want)
 	}
-	if want := "True/GatewayReporting"; got[ConditionActivatorReady] != want {
+	if want := "True/GatewayProgrammed"; got[ConditionActivatorReady] != want {
 		t.Fatalf("ActivatorReady = %q, want %q", got[ConditionActivatorReady], want)
 	}
 
 	evaluator = PolicyEvaluator{
 		Services:  services{"app/orders": true},
-		Streams:   subscribed{orders: true},
-		Reporters: reporters{},
+		Scaler:    scalerStatus(true),
+		Activator: activatorStatus(false),
 	}
 	got = conditionsByType(t, evaluator, validPolicy())
-	if want := "True/ScalerSubscribed"; got[ConditionScalerReady] != want {
+	if want := "True/ScaledObjectReady"; got[ConditionScalerReady] != want {
 		t.Fatalf("ScalerReady = %q, want %q", got[ConditionScalerReady], want)
 	}
-	if want := "False/NoGatewayReporting"; got[ConditionActivatorReady] != want {
+	if want := "False/NoProgrammedGateway"; got[ConditionActivatorReady] != want {
 		t.Fatalf("ActivatorReady = %q, want %q", got[ConditionActivatorReady], want)
 	}
 }
@@ -228,8 +230,8 @@ func TestEvaluateReportsScalerAndActivatorSeparately(t *testing.T) {
 func TestEvaluateIsStableAcrossCalls(t *testing.T) {
 	evaluator := PolicyEvaluator{
 		Services:  services{"app/orders": true},
-		Streams:   subscribed{orders: true},
-		Reporters: reporters{orders: 1},
+		Scaler:    scalerStatus(true),
+		Activator: activatorStatus(true),
 	}
 	target := validPolicy()
 
@@ -240,8 +242,8 @@ func TestEvaluateIsStableAcrossCalls(t *testing.T) {
 	// A real change must still be detected.
 	changed := PolicyEvaluator{
 		Services:  services{"app/orders": true},
-		Streams:   subscribed{},
-		Reporters: reporters{orders: 1},
+		Scaler:    scalerStatus(false),
+		Activator: activatorStatus(true),
 	}
 	if SameConditions(evaluator.Evaluate(target), changed.Evaluate(target)) {
 		t.Fatal("SameConditions() reported no change after the scaler unsubscribed")
diff --git a/dubbod/discovery/pkg/bootstrap/activation.go b/dubbod/discovery/pkg/bootstrap/activation.go
index 08bd9b25..bd341793 100644
--- a/dubbod/discovery/pkg/bootstrap/activation.go
+++ b/dubbod/discovery/pkg/bootstrap/activation.go
@@ -39,7 +39,7 @@ func (s *Server) initActivation(args *DubboArgs) error {
 		return nil
 	}
 
-	controller := activation.NewController(s.kubeClient, s.activation.Scaler(), s.activation.Registry())
+	controller := activation.NewController(s.kubeClient)
 	s.addStartFunc("activation policy controller", func(stop <-chan struct{}) error {
 		go controller.Run(stop)
 		return nil
diff --git a/dubbod/discovery/pkg/bootstrap/server.go b/dubbod/discovery/pkg/bootstrap/server.go
index 97cb738d..bcb49a48 100644
--- a/dubbod/discovery/pkg/bootstrap/server.go
+++ b/dubbod/discovery/pkg/bootstrap/server.go
@@ -535,6 +535,13 @@ func (s *Server) initRegistryEventHandlers() {
 			Namespace: cfg.Namespace,
 		}
 
+		if configKind == kind.ServiceActivationPolicy &&
+			event == model.EventUpdate &&
+			cfg.Generation == prev.Generation {
+			log.Debugf("ignoring status-only update for %s/%s/%s", configKey.Kind, configKey.Namespace, configKey.Name)
+			return
+		}
+
 		// Log the config change
 		log.Infof("%s event for %s/%s/%s", event, configKey.Kind, configKey.Namespace, configKey.Name)
 
@@ -553,11 +560,22 @@ func (s *Server) initRegistryEventHandlers() {
 			configKind == kind.ServiceActivationPolicy
 
 		// Trigger ConfigUpdate to push changes to all connected proxies
-		s.XDSServer.ConfigUpdate(&model.PushRequest{
+		pushRequest := &model.PushRequest{
 			ConfigsUpdated: sets.New(configKey),
 			Reason:         model.NewReasonStats(model.DependentResource),
 			Full:           needsFullPush,
-		})
+		}
+		if configKind == kind.ServiceActivationPolicy {
+			pushRequest.Forced = true
+			pushRequest.ServiceActivationPolicyUpdates = map[model.ConfigKey]model.ServiceActivationPolicyUpdate{
+				configKey: {
+					Config:   cfg,
+					Previous: prev,
+					Deleted:  event == model.EventDelete,
+				},
+			}
+		}
+		s.XDSServer.ConfigUpdate(pushRequest)
 	}
 	schemas := collections.Dubbo.All()
 	if features.EnableGatewayAPI {
diff --git a/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go b/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go
index 60769004..727adfda 100644
--- a/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go
+++ b/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go
@@ -47,6 +47,7 @@ import (
 	"github.com/apache/dubbo-kubernetes/pkg/config/constants"
 	"github.com/apache/dubbo-kubernetes/pkg/config/schema/gvr"
 	telemetryconfig "github.com/apache/dubbo-kubernetes/pkg/config/telemetry"
+	"github.com/apache/dubbo-kubernetes/pkg/grpcxds"
 	"github.com/apache/dubbo-kubernetes/pkg/kube"
 	"github.com/apache/dubbo-kubernetes/pkg/kube/controllers"
 	"github.com/apache/dubbo-kubernetes/pkg/kube/inject"
@@ -457,9 +458,29 @@ func (d *DeploymentController) configureGateway(log *dubbolog.Logger, gw gateway
 		}
 	}
 
-	if err := d.cleanupLegacyGatewayResources(context.TODO(), log, gw.Namespace, legacyName, defaultName); err != nil {
+	if err := d.cleanupLegacyGatewayResources(
+		context.TODO(),
+		log,
+		gw.Namespace,
+		legacyName,
+		defaultName,
+		gw.Name,
+	); err != nil {
 		log.Warnf("failed cleaning up legacy dxgate resources %s/%s: %v", gw.Namespace, legacyName, err)
 	}
+	if defaultName != defaultDxgateGatewayName {
+		if err := d.cleanupLegacyGatewayResources(
+			context.TODO(),
+			log,
+			gw.Namespace,
+			defaultDxgateGatewayName,
+			defaultName,
+			gw.Name,
+		); err != nil {
+			log.Warnf("failed cleaning up fixed-name dxgate resources %s/%s: %v",
+				gw.Namespace, defaultDxgateGatewayName, err)
+		}
+	}
 
 	log.Infof("gateway updated successfully")
 	return nil
@@ -718,12 +739,13 @@ func buildDxgateBootstrapConfig(xdsAddress string, listenerNames []string, clust
 }
 
 func dxgateListenerNames(namespace, serviceName, domainSuffix string, ports []corev1.ServicePort) []string {
-	if domainSuffix == "" {
-		domainSuffix = constants.DefaultClusterLocalDomain
-	}
 	out := make([]string, 0, len(ports))
 	for _, port := range ports {
-		out = append(out, fmt.Sprintf("%s.%s.svc.%s:%d", serviceName, namespace, domainSuffix, port.Port))
+		targetPort := port.TargetPort.IntValue()
+		if targetPort <= 0 {
+			targetPort = int(port.Port)
+		}
+		out = append(out, fmt.Sprintf("%s0.0.0.0:%d", grpcxds.ServerListenerNamePrefix, targetPort))
 	}
 	sort.Strings(out)
 	return out
@@ -1258,7 +1280,14 @@ func (d *DeploymentController) canManage(gvr schema.GroupVersionResource, name,
 	return managed, obj.GetResourceVersion()
 }
 
-func (d *DeploymentController) cleanupLegacyGatewayResources(ctx context.Context, log *dubbolog.Logger, namespace, legacyName, currentName string) error {
+func (d *DeploymentController) cleanupLegacyGatewayResources(
+	ctx context.Context,
+	log *dubbolog.Logger,
+	namespace,
+	legacyName,
+	currentName,
+	gatewayName string,
+) error {
 	if d.client == nil || legacyName == "" || legacyName == currentName {
 		return nil
 	}
@@ -1267,14 +1296,14 @@ func (d *DeploymentController) cleanupLegacyGatewayResources(ctx context.Context
 	for _, cleanup := range []struct {
 		kind string
 		name string
-		fn   func(context.Context, string, string) error
+		fn   func(context.Context, string, string, string) error
 	}{
 		{kind: "ConfigMap", name: legacyName + "-bootstrap", fn: d.deleteManagedConfigMap},
 		{kind: "ServiceAccount", name: legacyName, fn: d.deleteManagedServiceAccount},
 		{kind: "Deployment", name: legacyName, fn: d.deleteManagedDeployment},
 		{kind: "Service", name: legacyName, fn: d.deleteManagedService},
 	} {
-		if err := cleanup.fn(ctx, namespace, cleanup.name); err != nil {
+		if err := cleanup.fn(ctx, namespace, cleanup.name, gatewayName); err != nil {
 			errs = append(errs, fmt.Sprintf("%s/%s: %v", cleanup.kind, cleanup.name, err))
 		}
 	}
@@ -1286,7 +1315,7 @@ func (d *DeploymentController) cleanupLegacyGatewayResources(ctx context.Context
 	return nil
 }
 
-func (d *DeploymentController) deleteManagedConfigMap(ctx context.Context, namespace, name string) error {
+func (d *DeploymentController) deleteManagedConfigMap(ctx context.Context, namespace, name, gatewayName string) error {
 	obj, err := d.client.Kube().CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})
 	if apierrors.IsNotFound(err) {
 		return nil
@@ -1294,7 +1323,7 @@ func (d *DeploymentController) deleteManagedConfigMap(ctx context.Context, names
 	if err != nil {
 		return err
 	}
-	if !isManagedGatewayResource(obj.Labels) {
+	if !isManagedGatewayResourceFor(obj.Labels, gatewayName) {
 		return nil
 	}
 	err = d.client.Kube().CoreV1().ConfigMaps(namespace).Delete(ctx, name, metav1.DeleteOptions{})
@@ -1304,7 +1333,7 @@ func (d *DeploymentController) deleteManagedConfigMap(ctx context.Context, names
 	return err
 }
 
-func (d *DeploymentController) deleteManagedServiceAccount(ctx context.Context, namespace, name string) error {
+func (d *DeploymentController) deleteManagedServiceAccount(ctx context.Context, namespace, name, gatewayName string) error {
 	obj, err := d.client.Kube().CoreV1().ServiceAccounts(namespace).Get(ctx, name, metav1.GetOptions{})
 	if apierrors.IsNotFound(err) {
 		return nil
@@ -1312,7 +1341,7 @@ func (d *DeploymentController) deleteManagedServiceAccount(ctx context.Context,
 	if err != nil {
 		return err
 	}
-	if !isManagedGatewayResource(obj.Labels) {
+	if !isManagedGatewayResourceFor(obj.Labels, gatewayName) {
 		return nil
 	}
 	err = d.client.Kube().CoreV1().ServiceAccounts(namespace).Delete(ctx, name, metav1.DeleteOptions{})
@@ -1322,7 +1351,7 @@ func (d *DeploymentController) deleteManagedServiceAccount(ctx context.Context,
 	return err
 }
 
-func (d *DeploymentController) deleteManagedDeployment(ctx context.Context, namespace, name string) error {
+func (d *DeploymentController) deleteManagedDeployment(ctx context.Context, namespace, name, gatewayName string) error {
 	obj, err := d.client.Kube().AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
 	if apierrors.IsNotFound(err) {
 		return nil
@@ -1330,7 +1359,7 @@ func (d *DeploymentController) deleteManagedDeployment(ctx context.Context, name
 	if err != nil {
 		return err
 	}
-	if !isManagedGatewayResource(obj.Labels) {
+	if !isManagedGatewayResourceFor(obj.Labels, gatewayName) {
 		return nil
 	}
 	err = d.client.Kube().AppsV1().Deployments(namespace).Delete(ctx, name, metav1.DeleteOptions{})
@@ -1340,7 +1369,7 @@ func (d *DeploymentController) deleteManagedDeployment(ctx context.Context, name
 	return err
 }
 
-func (d *DeploymentController) deleteManagedService(ctx context.Context, namespace, name string) error {
+func (d *DeploymentController) deleteManagedService(ctx context.Context, namespace, name, gatewayName string) error {
 	obj, err := d.client.Kube().CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{})
 	if apierrors.IsNotFound(err) {
 		return nil
@@ -1348,7 +1377,7 @@ func (d *DeploymentController) deleteManagedService(ctx context.Context, namespa
 	if err != nil {
 		return err
 	}
-	if !isManagedGatewayResource(obj.Labels) {
+	if !isManagedGatewayResourceFor(obj.Labels, gatewayName) {
 		return nil
 	}
 	err = d.client.Kube().CoreV1().Services(namespace).Delete(ctx, name, metav1.DeleteOptions{})
@@ -1363,6 +1392,11 @@ func isManagedGatewayResource(labels map[string]string) bool {
 	return ok
 }
 
+func isManagedGatewayResourceFor(labels map[string]string, gatewayName string) bool {
+	return isManagedGatewayResource(labels) &&
+		labels["gateway.networking.k8s.io/gateway-name"] == gatewayName
+}
+
 func (d *DeploymentController) HandleTagChange(newTags any) {
 	for _, gw := range d.gateways.List(metav1.NamespaceAll, klabels.Everything()) {
 		d.queue.AddObject(gw)
@@ -1384,8 +1418,14 @@ func IsManaged(gw *gateway.GatewaySpec) bool {
 	return false
 }
 
-func getDefaultName(_ string, _ *gateway.GatewaySpec, _ bool) string {
-	return defaultDxgateGatewayName
+func getDefaultName(name string, kgw *gateway.GatewaySpec, disableNameSuffix bool) string {
+	// Keep the canonical Activator Service stable: proxyless cold EDS points at
+	// this namespace-local name. Every other Gateway needs its own resources or
+	// two Gateway reconciles overwrite the same Deployment, Service and config.
+	if name == defaultDxgateGatewayName {
+		return defaultDxgateGatewayName
+	}
+	return getLegacyDefaultName(name, kgw, disableNameSuffix)
 }
 
 func getLegacyDefaultName(name string, kgw *gateway.GatewaySpec, disableNameSuffix bool) string {
diff --git a/dubbod/discovery/pkg/config/kube/gateway/deployment_controller_test.go b/dubbod/discovery/pkg/config/kube/gateway/deployment_controller_test.go
index b7b0da8b..826f4701 100644
--- a/dubbod/discovery/pkg/config/kube/gateway/deployment_controller_test.go
+++ b/dubbod/discovery/pkg/config/kube/gateway/deployment_controller_test.go
@@ -441,6 +441,12 @@ func TestDeploymentControllerBuildDxgateBootstrapConfigUsesXDSAddressAnnotation(
 	if cfg.ClusterID != "remote" {
 		t.Fatalf("clusterID = %q, want remote", cfg.ClusterID)
 	}
+	if diff := cmp.Diff(
+		[]string{"xds.dubbo.apache.org/grpc/lds/inbound/0.0.0.0:15080"},
+		cfg.ListenerNames,
+	); diff != "" {
+		t.Fatalf("listener names (-want +got):\n%s", diff)
+	}
 }
 
 func TestExtractServicePortsTargetsGRPCInbound(t *testing.T) {
@@ -612,13 +618,29 @@ func TestObservabilityConfigForGatewayInvalidAccessLogAnnotationsFallBack(t *tes
 	}
 }
 
-func TestGetDefaultNameUsesFixedDxgateGatewayName(t *testing.T) {
+func TestGetDefaultNameKeepsCanonicalActivatorAndIsolatesOtherGateways(t *testing.T) {
 	spec := &gatewayv1.GatewaySpec{GatewayClassName: "dubbo"}
-	if got := getDefaultName("httpbin-gateway", spec, false); got != "dxgate-gateway" {
-		t.Fatalf("default name = %q, want dxgate-gateway", got)
+	if got := getDefaultName("dxgate-gateway", spec, false); got != "dxgate-gateway" {
+		t.Fatalf("canonical name = %q, want dxgate-gateway", got)
+	}
+	if got := getDefaultName("public", spec, false); got != "public-dubbo" {
+		t.Fatalf("derived name = %q, want public-dubbo", got)
+	}
+	if got := getDefaultName("private", spec, true); got != "private" {
+		t.Fatalf("name with suffix disabled = %q, want private", got)
+	}
+}
+
+func TestManagedGatewayResourceCleanupRequiresMatchingGateway(t *testing.T) {
+	labels := map[string]string{
+		"gateway.dubbo.apache.org/managed":       "dubbo.apache.org-gateway-controller",
+		"gateway.networking.k8s.io/gateway-name": "public",
+	}
+	if !isManagedGatewayResourceFor(labels, "public") {
+		t.Fatal("matching managed resource was not recognized")
 	}
-	if got := getDefaultName("foo-gateway", spec, true); got != "dxgate-gateway" {
-		t.Fatalf("default name with suffix disabled = %q, want dxgate-gateway", got)
+	if isManagedGatewayResourceFor(labels, "dxgate-gateway") {
+		t.Fatal("cleanup would delete another Gateway's resources")
 	}
 }
 
diff --git a/dubbod/discovery/pkg/model/push_context.go b/dubbod/discovery/pkg/model/push_context.go
index 075769e7..b9bf9a9c 100644
--- a/dubbod/discovery/pkg/model/push_context.go
+++ b/dubbod/discovery/pkg/model/push_context.go
@@ -87,14 +87,21 @@ type PushContext struct {
 }
 
 type PushRequest struct {
-	Reason           ReasonStats
-	ConfigsUpdated   sets.Set[ConfigKey]
-	AddressesUpdated sets.Set[string]
-	Forced           bool
-	Full             bool
-	Push             *PushContext
-	Start            time.Time
-	Delta            ResourceDelta
+	Reason                         ReasonStats
+	ConfigsUpdated                 sets.Set[ConfigKey]
+	ServiceActivationPolicyUpdates map[ConfigKey]ServiceActivationPolicyUpdate
+	AddressesUpdated               sets.Set[string]
+	Forced                         bool
+	Full                           bool
+	Push                           *PushContext
+	Start                          time.Time
+	Delta                          ResourceDelta
+}
+
+type ServiceActivationPolicyUpdate struct {
+	Config   config.Config
+	Previous config.Config
+	Deleted  bool
 }
 
 type XDSUpdater interface {
@@ -310,6 +317,18 @@ func (pr *PushRequest) Merge(other *PushRequest) *PushRequest {
 	} else {
 		pr.AddressesUpdated.Merge(other.AddressesUpdated)
 	}
+	if len(other.ServiceActivationPolicyUpdates) > 0 {
+		if pr.ServiceActivationPolicyUpdates == nil {
+			pr.ServiceActivationPolicyUpdates = make(map[ConfigKey]ServiceActivationPolicyUpdate)
+		}
+		for key, update := range other.ServiceActivationPolicyUpdates {
+			if existing, found := pr.ServiceActivationPolicyUpdates[key]; found &&
+				existing.Previous.Spec != nil {
+				update.Previous = existing.Previous
+			}
+			pr.ServiceActivationPolicyUpdates[key] = update
+		}
+	}
 
 	pr.Delta = mergeResourceDelta(pr.Delta, other.Delta)
 
@@ -358,6 +377,12 @@ func (pr *PushRequest) Copy() *PushRequest {
 	if pr.AddressesUpdated != nil {
 		out.AddressesUpdated = pr.AddressesUpdated.Copy()
 	}
+	if pr.ServiceActivationPolicyUpdates != nil {
+		out.ServiceActivationPolicyUpdates = make(map[ConfigKey]ServiceActivationPolicyUpdate, len(pr.ServiceActivationPolicyUpdates))
+		for key, update := range pr.ServiceActivationPolicyUpdates {
+			out.ServiceActivationPolicyUpdates[key] = update
+		}
+	}
 	out.Delta = copyResourceDelta(pr.Delta)
 	return &out
 }
@@ -432,6 +457,7 @@ func (ps *PushContext) InitContext(env *Environment, oldPushContext *PushContext
 
 	if pushReq == nil || oldPushContext == nil || !oldPushContext.InitDone.Load() || pushReq.Forced {
 		ps.createNewContext(env)
+		ps.applyServiceActivationPolicyUpdates(pushReq)
 	} else {
 		ps.updateContext(env, oldPushContext, pushReq)
 	}
@@ -609,13 +635,14 @@ func (ps *PushContext) createNewContext(env *Environment) {
 }
 
 func (ps *PushContext) updateContext(env *Environment, oldPushContext *PushContext, pushReq *PushRequest) {
-	// Check if services have changed based on:
-	// 1. ServiceEntry updates in ConfigsUpdated
-	// 2. Address changes
-	// 3. Actual service count changes from environment (for Kubernetes Service changes)
-	// servicesChanged := pushReq != nil && (HasConfigsOfKind(pushReq.ConfigsUpdated, kind.ServiceEntry) ||
-	// 	len(pushReq.AddressesUpdated) > 0)
-	servicesChanged := pushReq != nil && len(pushReq.AddressesUpdated) > 0
+	// A Service may appear after a policy in the same informer burst. Rebuild
+	// the registry on the explicit Service event even when the total count was
+	// already visible through env.Services(); otherwise one HA replica can keep
+	// an activation RDS snapshot that permanently omits the new backend.
+	servicesChanged := pushReq != nil &&
+		(len(pushReq.AddressesUpdated) > 0 ||
+			HasConfigsOfKind(pushReq.ConfigsUpdated, kind.Service) ||
+			HasConfigsOfKind(pushReq.ConfigsUpdated, kind.ServiceEntry))
 
 	// Also check if the actual number of services has changed
 	// This handles cases where Kubernetes Services are added/removed without ServiceEntry updates
@@ -677,13 +704,8 @@ func (ps *PushContext) updateContext(env *Environment, oldPushContext *PushConte
 		ps.faultInjectionIndex = oldPushContext.faultInjectionIndex
 	}
 
-	serviceActivationPoliciesChanged := pushReq != nil && HasConfigsOfKind(pushReq.ConfigsUpdated, kind.ServiceActivationPolicy)
-	if serviceActivationPoliciesChanged {
-		log.Debugf("ServiceActivationPolicies changed, re-initializing activation index")
-		ps.initServiceActivationPolicies(env)
-	} else {
-		ps.serviceActivationIndex = oldPushContext.serviceActivationIndex
-	}
+	ps.serviceActivationIndex = copyServiceActivationPolicyIndex(oldPushContext.serviceActivationIndex)
+	ps.applyServiceActivationPolicyUpdates(pushReq)
 
 	authnPoliciesChanged := pushReq != nil && (pushReq.Full || authPolicyKindsChanged(pushReq.ConfigsUpdated))
 	if authnPoliciesChanged || oldPushContext == nil || oldPushContext.AuthenticationPolicies == nil {
@@ -1122,22 +1144,60 @@ func (ps *PushContext) initServiceActivationPolicies(env *Environment) {
 	if env == nil {
 		return
 	}
-	for _, cfg := range sortConfigByCreationTime(env.List(gvk.ServiceActivationPolicy, NamespaceAll)) {
-		spec, ok := cfg.Spec.(*networking.ServiceActivationPolicy)
-		if !ok || spec == nil || spec.GetTargetRef() == nil || spec.GetAutoscalerRef() == nil {
-			continue
+	configs := sortConfigByCreationTime(env.List(gvk.ServiceActivationPolicy, NamespaceAll))
+	for _, cfg := range configs {
+		ps.upsertServiceActivationPolicy(cfg)
+	}
+	log.Debugf("activation policy index rebuilt: configs=%d services=%d", len(configs), len(ps.serviceActivationIndex.services))
+}
+
+func copyServiceActivationPolicyIndex(in serviceActivationPolicyIndex) serviceActivationPolicyIndex {
+	out := serviceActivationPolicyIndex{services: make(map[string][]string, len(in.services))}
+	for key, accounts := range in.services {
+		out.services[key] = append([]string(nil), accounts...)
+	}
+	return out
+}
+
+func (ps *PushContext) applyServiceActivationPolicyUpdates(pushReq *PushRequest) {
+	if pushReq == nil {
+		return
+	}
+	for _, update := range pushReq.ServiceActivationPolicyUpdates {
+		if update.Previous.Spec != nil {
+			ps.deleteServiceActivationPolicy(update.Previous)
 		}
-		target := spec.GetTargetRef()
-		if strings.TrimSpace(target.GetName()) == "" ||
-			(target.GetKind() != "" && !strings.EqualFold(target.GetKind(), "Service")) ||
-			strings.TrimSpace(target.GetGroup()) != "" ||
-			strings.TrimSpace(spec.GetAutoscalerRef().GetName()) == "" ||
-			len(spec.GetBackendServiceAccounts()) == 0 {
+		if update.Deleted {
+			ps.deleteServiceActivationPolicy(update.Config)
 			continue
 		}
-		ps.serviceActivationIndex.services[backendTLSPolicyServiceKey(cfg.Namespace, target.GetName())] =
-			append([]string(nil), spec.GetBackendServiceAccounts()...)
+		ps.upsertServiceActivationPolicy(update.Config)
+	}
+}
+
+func (ps *PushContext) deleteServiceActivationPolicy(cfg config.Config) {
+	spec, ok := cfg.Spec.(*networking.ServiceActivationPolicy)
+	if ok && spec != nil && spec.GetTargetRef() != nil {
+		delete(ps.serviceActivationIndex.services,
+			backendTLSPolicyServiceKey(cfg.Namespace, spec.GetTargetRef().GetName()))
+	}
+}
+
+func (ps *PushContext) upsertServiceActivationPolicy(cfg config.Config) {
+	spec, ok := cfg.Spec.(*networking.ServiceActivationPolicy)
+	if !ok || spec == nil || spec.GetTargetRef() == nil || spec.GetAutoscalerRef() == nil {
+		return
+	}
+	target := spec.GetTargetRef()
+	if strings.TrimSpace(target.GetName()) == "" ||
+		(target.GetKind() != "" && !strings.EqualFold(target.GetKind(), "Service")) ||
+		strings.TrimSpace(target.GetGroup()) != "" ||
+		strings.TrimSpace(spec.GetAutoscalerRef().GetName()) == "" ||
+		len(spec.GetBackendServiceAccounts()) == 0 {
+		return
 	}
+	ps.serviceActivationIndex.services[backendTLSPolicyServiceKey(cfg.Namespace, target.GetName())] =
+		append([]string(nil), spec.GetBackendServiceAccounts()...)
 }
 
 func (ps *PushContext) initServiceAccounts(env *Environment, services []*Service) {
diff --git a/dubbod/discovery/pkg/model/push_context_test.go b/dubbod/discovery/pkg/model/push_context_test.go
index 02f95714..d22cdc56 100644
--- a/dubbod/discovery/pkg/model/push_context_test.go
+++ b/dubbod/discovery/pkg/model/push_context_test.go
@@ -20,8 +20,11 @@ import (
 	"testing"
 	"time"
 
+	"github.com/apache/dubbo-kubernetes/pkg/config"
+	"github.com/apache/dubbo-kubernetes/pkg/config/schema/gvk"
 	"github.com/apache/dubbo-kubernetes/pkg/config/schema/kind"
 	"github.com/apache/dubbo-kubernetes/pkg/util/sets"
+	networking "github.com/kdubbo/api/networking/v1alpha3"
 )
 
 func TestPushRequestCopyMergePreservesQueuedUpdates(t *testing.T) {
@@ -99,3 +102,49 @@ func TestPushContextStatusJSONEmptyObject(t *testing.T) {
 		t.Fatalf("StatusJSON() = %s, want {}", string(data))
 	}
 }
+
+func TestApplyServiceActivationPolicyUpdates(t *testing.T) {
+	key := ConfigKey{Kind: kind.ServiceActivationPolicy, Name: "payment", Namespace: "app"}
+	policy := config.Config{
+		Meta: config.Meta{
+			GroupVersionKind: gvk.ServiceActivationPolicy,
+			Name:             key.Name,
+			Namespace:        key.Namespace,
+		},
+		Spec: &networking.ServiceActivationPolicy{
+			TargetRef: &networking.PolicyTargetReference{
+				Kind: "Service",
+				Name: "payment",
+			},
+			AutoscalerRef:          &networking.AutoscalerReference{Name: "payment"},
+			BackendServiceAccounts: []string{"payment"},
+		},
+	}
+	add := &PushRequest{
+		ServiceActivationPolicyUpdates: map[ConfigKey]ServiceActivationPolicyUpdate{
+			key: {Config: policy},
+		},
+	}
+
+	push := NewPushContext()
+	push.applyServiceActivationPolicyUpdates(add)
+	if !push.ServiceActivationEnabled("app", "payment") {
+		t.Fatal("policy event did not add payment to activation index")
+	}
+
+	remove := &PushRequest{
+		ServiceActivationPolicyUpdates: map[ConfigKey]ServiceActivationPolicyUpdate{
+			key: {Config: policy, Deleted: true},
+		},
+	}
+	merged := add.CopyMerge(remove)
+	if !merged.ServiceActivationPolicyUpdates[key].Deleted {
+		t.Fatal("later delete did not replace queued add")
+	}
+	next := NewPushContext()
+	next.serviceActivationIndex = copyServiceActivationPolicyIndex(push.serviceActivationIndex)
+	next.applyServiceActivationPolicyUpdates(merged)
+	if next.ServiceActivationEnabled("app", "payment") {
+		t.Fatal("policy delete did not remove payment from activation index")
+	}
+}
diff --git a/dubbod/discovery/pkg/networking/grpcgen/rds.go b/dubbod/discovery/pkg/networking/grpcgen/rds.go
index b585fac2..7a7df64e 100644
--- a/dubbod/discovery/pkg/networking/grpcgen/rds.go
+++ b/dubbod/discovery/pkg/networking/grpcgen/rds.go
@@ -179,18 +179,24 @@ func buildHTTPRoute(node *model.Proxy, push *model.PushContext, routeName string
 	// For regular service Pods, use NonForwardingAction to handle requests directly
 	// Also check if this is a Gateway Pod by checking service name (fallback for when node.Type is not Router)
 	isGatewayPod := false
+	isGatewayDataPort := parsedPort == 80
+	gatewayListenerPort := parsedPort
 	var gatewayName, gatewayNamespace string
-	// Try to find Gateway Pod's service and extract Gateway name from labels
+	// Resolve the listener's Service port from the generated Gateway Service.
+	// dxgate binds the Service targetPort (15080 by default), while Gateway API
+	// parentRefs and HTTPRoutes refer to the public listener port (for example 80).
 	for _, st := range node.ServiceTargets {
-		if strings.Contains(strings.ToLower(st.Service.Attributes.Name), "gateway") {
+		if st.Service == nil {
+			continue
+		}
+		if name, ok := st.Service.Attributes.Labels["gateway.networking.k8s.io/gateway-name"]; ok {
 			isGatewayPod = true
-			// Try to get Gateway name from service labels
-			if len(st.Service.Attributes.Labels) > 0 {
-				if name, ok := st.Service.Attributes.Labels["gateway.networking.k8s.io/gateway-name"]; ok {
-					gatewayName = name
-					gatewayNamespace = st.Service.Attributes.Namespace
-					break
-				}
+			if st.Port.ServicePort != nil && st.Port.TargetPort == uint32(parsedPort) {
+				gatewayName = name
+				gatewayNamespace = st.Service.Attributes.Namespace
+				gatewayListenerPort = st.Port.Port
+				isGatewayDataPort = true
+				break
 			}
 		}
 	}
@@ -200,10 +206,8 @@ func buildHTTPRoute(node *model.Proxy, push *model.PushContext, routeName string
 		}
 		log.Infof("Gateway Pod inbound listener, routeName=%s, port=%d, gateway=%s/%s", routeName, parsedPort, gatewayNamespace, gatewayName)
 
-		// CRITICAL: Only apply HTTPRoute to the Gateway listener port (80)
-		// Other ports (26012, 26021, etc.) are service ports and should not use HTTPRoute
-		if parsedPort != 80 {
-			log.Debugf("Gateway Pod inbound listener port %d is not 80, skipping HTTPRoute (this is a service port, not Gateway listener)", parsedPort)
+		if !isGatewayDataPort {
+			log.Debugf("Gateway Pod inbound listener port %d is not a Gateway Service targetPort, skipping HTTPRoute", parsedPort)
 			// Return empty route config for non-Gateway ports
 			return &route.RouteConfiguration{
 				Name: routeName,
@@ -236,8 +240,8 @@ func buildHTTPRoute(node *model.Proxy, push *model.PushContext, routeName string
 		log.Debugf("Gateway Pod inbound listener, found %d HTTPRoute(s) with wildcard match", len(allHTTPRoutes))
 
 		// Filter HTTPRoutes by parentRef to match this Gateway
-		httpRoutes := filterHTTPRoutesByGateway(allHTTPRoutes, gatewayName, gatewayNamespace, parsedPort)
-		log.Debugf("Gateway Pod inbound listener, filtered to %d HTTPRoute(s) matching gateway %s/%s port %d", len(httpRoutes), gatewayNamespace, gatewayName, parsedPort)
+		httpRoutes := filterHTTPRoutesByGateway(allHTTPRoutes, gatewayName, gatewayNamespace, gatewayListenerPort)
+		log.Debugf("Gateway Pod inbound listener, filtered to %d HTTPRoute(s) matching gateway %s/%s listener port %d", len(httpRoutes), gatewayNamespace, gatewayName, gatewayListenerPort)
 
 		// For Gateway Pod, we also need to collect HTTPRoutes with specific hostnames
 		// because Gateway Pods route traffic based on HTTPRoute hostnames in the request
@@ -271,7 +275,7 @@ func buildHTTPRoute(node *model.Proxy, push *model.PushContext, routeName string
 				}
 			}
 
-			if routes := buildRoutesFromGatewayHTTPRoute(httpRoutes, host.Name("*"), parsedPort, nil); len(routes) > 0 {
+			if routes := buildRoutesFromGatewayHTTPRoute(httpRoutes, host.Name("*"), gatewayListenerPort, nil); len(routes) > 0 {
 				log.Infof("Gateway Pod inbound listener built %d routes from HTTPRoute", len(routes))
 				outboundRoutes = routes
 			} else {
diff --git a/dubbod/discovery/pkg/networking/grpcgen/rds_test.go b/dubbod/discovery/pkg/networking/grpcgen/rds_test.go
index e4075a05..d2b035c9 100644
--- a/dubbod/discovery/pkg/networking/grpcgen/rds_test.go
+++ b/dubbod/discovery/pkg/networking/grpcgen/rds_test.go
@@ -296,6 +296,45 @@ func TestGatewayRDSRoutesActivationAuthorityToOriginalCluster(t *testing.T) {
 	}
 }
 
+func TestGatewayInboundTargetPortIncludesActivationRoutes(t *testing.T) {
+	target := newRDSTestService("payment", "app", "payment.app.svc.cluster.local", 8080)
+	activator := newRDSTestService(
+		model.ActivationGatewayServiceName,
+		"app",
+		"dxgate-gateway.app.svc.cluster.local",
+		80,
+	)
+	activator.Attributes.Labels = map[string]string{
+		"gateway.networking.k8s.io/gateway-name": "dxgate-gateway",
+	}
+	push := newRDSTestPushContext(t, []config.Config{
+		newActivationPolicyConfig("payment", "app", "payment"),
+	}, []*model.Service{target, activator})
+	proxy := &model.Proxy{
+		ID:              "dxgate-gateway.app",
+		Type:            model.Router,
+		ConfigNamespace: "app",
+		ServiceTargets: []model.ServiceTarget{{
+			Service: activator,
+			Port: model.ServiceInstancePort{
+				ServicePort: activator.Ports[0],
+				TargetPort:  15080,
+			},
+		}},
+	}
+
+	rc := buildHTTPRoute(proxy, push, "15080")
+	if rc == nil {
+		t.Fatal("buildHTTPRoute() returned nil")
+	}
+	for _, virtualHost := range rc.GetVirtualHosts() {
+		if virtualHost.GetName() == "activation|payment.app.svc.cluster.local|8080" {
+			return
+		}
+	}
+	t.Fatalf("activation virtual host not found on Gateway targetPort: %v", rc.GetVirtualHosts())
+}
+
 func newRDSTestPushContext(t *testing.T, configs []config.Config, services []*model.Service) *model.PushContext {
 	t.Helper()
 
diff --git a/dubbod/discovery/pkg/xds/delta_test.go b/dubbod/discovery/pkg/xds/delta_test.go
index 992060a0..84a6f4e6 100644
--- a/dubbod/discovery/pkg/xds/delta_test.go
+++ b/dubbod/discovery/pkg/xds/delta_test.go
@@ -80,6 +80,12 @@ func TestPushDeltaXdsAppliesRemovedResourcesToWatchedState(t *testing.T) {
 	assertWatchedNames(t, con.proxy.GetWatchedResource(v1.ClusterType), keptName, addedName)
 }
 
+func TestShouldSetWatchedResourcesTracksRoutes(t *testing.T) {
+	if !shouldSetWatchedResources(&model.WatchedResource{TypeUrl: v1.RouteType}) {
+		t.Fatal("Delta RDS responses must update watched resource names")
+	}
+}
+
 func newDeltaXDSTestServer(generator model.XdsResourceGenerator) (*DiscoveryServer, *Connection, *fakeDeltaADSStream) {
 	push := model.NewPushContext()
 	push.PushVersion = "test-version"
diff --git a/dubbod/discovery/pkg/xds/xdsgen.go b/dubbod/discovery/pkg/xds/xdsgen.go
index c10a14e4..d1c2c6e5 100644
--- a/dubbod/discovery/pkg/xds/xdsgen.go
+++ b/dubbod/discovery/pkg/xds/xdsgen.go
@@ -657,7 +657,10 @@ func shouldSetWatchedResources(w *model.WatchedResource) bool {
 	if w == nil {
 		return false
 	}
-	return xds.IsWildcardTypeURL(w.TypeUrl)
+	// RDS is not globally wildcard in xDS, but dxgate starts with a wildcard
+	// Delta subscription and then ACKs without repeating concrete names. Keep
+	// the names we actually sent so later policy pushes can rebuild those routes.
+	return xds.IsWildcardTypeURL(w.TypeUrl) || w.TypeUrl == v1.RouteType
 }
 
 // extractRouteNamesFromLDS extracts route names referenced in LDS listener resources
diff --git a/manifests/charts/dubbod/templates/clusterrole.yaml b/manifests/charts/dubbod/templates/clusterrole.yaml
index 412f6c5c..751e0f2b 100644
--- a/manifests/charts/dubbod/templates/clusterrole.yaml
+++ b/manifests/charts/dubbod/templates/clusterrole.yaml
@@ -72,6 +72,12 @@ rules:
   - apiGroups: ["coordination.k8s.io"]
     resources: ["leases"]
     verbs: ["get", "list", "watch", "create", "update", "patch"]
+  # ServiceActivationPolicy status follows the referenced KEDA object. This is
+  # read-only and optional: the delayed informer remains dormant when KEDA is
+  # not installed.
+  - apiGroups: ["keda.sh"]
+    resources: ["scaledobjects"]
+    verbs: ["get", "list", "watch"]
   - apiGroups: ["gateway.networking.k8s.io"]
     resources:
       - gateways
diff --git a/manifests/charts/dubbod/templates/deployment.yaml b/manifests/charts/dubbod/templates/deployment.yaml
index 88dcbe02..09e38679 100644
--- a/manifests/charts/dubbod/templates/deployment.yaml
+++ b/manifests/charts/dubbod/templates/deployment.yaml
@@ -39,6 +39,7 @@
 {{- $replicaCount := int (coalesce .Values.replicaCount $defaults.replicaCount 1) }}
 {{- $gateway := $global.gateway | default dict }}
 {{- $defaultGateway := $defaultGlobal.gateway | default dict }}
+{{- $gatewayImage := coalesce $gateway.image $defaultGateway.image "kdubbo/dxgate:latest" }}
 {{- $gatewayReplicas := int (coalesce $gateway.replicaCount $defaultGateway.replicaCount 2) }}
 {{- $remoteAccessCertificateHosts := $remoteAccess.certificateHosts | default $defaultRemoteAccess.certificateHosts | default (list) }}
 {{- $eastWestGateways := $eastWestGateway.gateways | default $defaultEastWestGateway.gateways | default (list) }}
@@ -140,6 +141,8 @@ spec:
               value: default
             - name: DUBBO_CERT_PROVIDER
               value: dubbod
+            - name: DUBBO_DXGATE_IMAGE
+              value: {{ $gatewayImage | quote }}
 {{- if gt (len $remoteAccessCertificateHosts) 0 }}
             - name: DUBBOD_CUSTOM_HOST
               value: {{ join "," $remoteAccessCertificateHosts | quote }}
diff --git a/manifests/charts/dubbod/values.yaml b/manifests/charts/dubbod/values.yaml
index 04358956..c006466d 100644
--- a/manifests/charts/dubbod/values.yaml
+++ b/manifests/charts/dubbod/values.yaml
@@ -49,6 +49,10 @@ _internal_default_values_not_set:
       port: 26080
 
     gateway:
+      # Image used by managed Gateway deployments. Keep it independently
+      # configurable from dubbod/CNI so a gateway can be rolled forward or
+      # back without replacing the control plane.
+      image: "kdubbo/dxgate:latest"
       # Default replica count for every managed dxgate Deployment. An
       # individual Gateway overrides it with the
       # gateway.dubbo.apache.org/replicas annotation.
diff --git a/samples/activation/README.md b/samples/activation/README.md
index 173ffff3..2dd23501 100644
--- a/samples/activation/README.md
+++ b/samples/activation/README.md
@@ -19,7 +19,7 @@
        EDS 下发新端点 -> dxgate 放行被扣住的请求
 ```
 
-网关是唯一能做这件事的位置。出向是 proxyless 的,调用方进程内的 gRPC xDS client 直接拿 EDS 端点建连,端点为空时当场失败,请求路径上没有任何东西活得够久去触发扩容。而进到网关的请求已经在网关自己的 task 里,可以等。
+南北向请求直接经过 dxgate。东西向请求原本由 proxyless 调用方直接读 EDS;冷服务的 EDS 现在会临时改写为专用 Activator `dxgate-gateway`,所以同一个扣流和上报机制也能接住服务间调用。后端就绪后,EDS 恢复真实端点,新请求直接访问后端。
 
 网关只负责等和上报,副本数始终由 KEDA 写。这条边界是有意的:网关重启不会把某个工作负载留在没人要求过的副本数上。
 
@@ -30,7 +30,7 @@ helm repo add kedacore https://kedacore.github.io/charts
 helm install keda kedacore/keda -n keda --create-namespace
 ```
 
-需要一个 Gateway API 的 Gateway 把流量导到 `payment`。`dubbod` 会为它拉起 dxgate,并注入 `DXGATE_ACTIVATION_CONTROL_PLANE`。
+需要一个名为 `dxgate-gateway` 的 Gateway 作为命名空间内的专用 Activator。`dubbod` 会为它拉起 dxgate,并注入 `DXGATE_ACTIVATION_CONTROL_PLANE`。其他 Gateway 使用各自派生的 Deployment/Service 名称,不会覆盖或清理 Activator 资源。
 
 ### 部署
 
@@ -60,8 +60,8 @@ time curl -s http://$GATEWAY/payment/healthz
 请求会挂住几秒——那是冷启动——然后正常返回,不是 503。同一时刻看网关:
 
 ```bash
-kubectl -n dubbo-system exec deploy/dxgate -- \
-  curl -s localhost:15021/metrics | grep dxgate_activation_requests_held
+kubectl -n activation port-forward deploy/dxgate-gateway 15021:26021
+curl -s localhost:15021/metrics | grep dxgate_activation_requests_held
 # dxgate_activation_requests_held 1
 ```
 
@@ -73,7 +73,7 @@ kubectl -n dubbo-system exec deploy/dxgate -- \
 kubectl -n activation get serviceactivationpolicy payment -o jsonpath='{.status.conditions}' | jq
 ```
 
-四个 condition:`Accepted` 策略本身合法,`Eligible` 目标可被激活,`ScalerReady` KEDA 能拿到指标,`ActivatorReady` 有网关在上报。
+四个 condition:`Accepted` 策略本身合法,`Eligible` 目标可被激活,`ScalerReady` 引用的 KEDA `ScaledObject` 已 Ready,`ActivatorReady` 同命名空间至少一个 Dubbo Gateway 已 Programmed。后两项读取 Kubernetes 共享状态,HA 副本不会因各自持有不同连接而互相覆盖。
 
 ### 三个组件各自负责什么
 
@@ -119,20 +119,26 @@ kubectl -n activation get serviceactivationpolicy payment -o jsonpath='{.status.
 
 `dxplane_connections_force_closed_total` 非零说明 25s 不够,调 `DUBBO_GRPC_INBOUND_TERMINATION_DRAIN_DURATION`。
 
-### 为什么 scaler 地址是 headless 的
+### 为什么网关上报和 KEDA 查询使用不同地址
 
 ```yaml
-scalerAddress: dubbod-activation-replicas.dubbo-system.svc.cluster.local:26030
+scalerAddress: dubbod-activation.dubbo-system.svc.cluster.local:26030
 ```
 
-不是 `dubbod-activation`。KEDA 查询会落到某一个副本上,而网关上报也只会到某一个副本——如果两边落到不同副本,KEDA 看到的 pending 永远是 0。所以网关向 headless 名解析出的**每一个**地址都上报一份,这样无论 KEDA 问到谁都能拿到数。
+ScaledObject 使用负载均衡的 `dubbod-activation`,无论 KEDA 落到哪个控制面副本都能拿到相同 pending。网关上报使用 headless 的 `dubbod-activation-replicas`,向解析出的**每一个**地址各上报一份,保证每个控制面副本都有相同数据。
 
 同理,网关必须注入 `POD_NAME`:控制面按 reporter 身份聚合,两个网关副本共用一个身份会互相覆盖。这个变量由 `kube-gateway.yaml` 自动注入。
 
-### 东西向还不行
+### 东西向和 mTLS
 
-这个样例是南北向的:请求从网关进来。
+带 `ServiceActivationPolicy` 的冷服务不会收到空 EDS。`dubbod` 把端点临时改成同命名空间 `dxgate-gateway` 的地址;Activator RDS 再按原始 Host 路由到真实服务。扩容完成后只切 EDS,不切 CDS。
 
-服务之间的调用(东西向)目前不能缩到零。调用方是 proxyless 的,直接读 EDS 建连,中间没有网关。`dubbod/discovery/pkg/xds` 还不感知激活状态,缩到零时下发的是空端点列表,调用方直接失败。
+`backendServiceAccounts` 是生产必填项。CDS 的 `MatchSubjectAltNames` 始终包含后端身份和 Activator 身份,冷/热切换期间 SAN 集合保持不变,避免证书校验窗口。不要为了省配置使用通配 SAN。
 
-所以只被其他服务调用、不经过网关的服务,仍然用 `minReplicaCount: 1`。
+### 生产边界
+
+- 只支持 HTTP 和 unary gRPC;流式 RPC、长连接、启动时间超过调用方 deadline 的服务保持 `minReplicaCount: 1`。
+- Activator 和 dubbod 都至少两个副本,并配置 PodDisruptionBudget。控制面 pending 是内存状态;全部控制面同时重启时,在网关下一次上报前 KEDA 暂时读到 0。
+- `maxPendingRequests` 和网关全局 backlog 都要压测。满载时按 `failurePolicy` 快速失败,不承诺无限排队。
+- 监控 `dxgate_activation_requests_held`、请求 4xx/5xx、KEDA ScaledObject/HPA 条件、策略的 `ScalerReady`/`ActivatorReady`。告警必须覆盖“pending 持续上升但副本仍为 0”。
+- 升级先保持目标至少一个副本,升级 CRD/base、dubbod、dxgate 后确认两种 SAN 和 Activator RDS 已下发,再恢复 `minReplicaCount: 0`。
diff --git a/samples/activation/activation-policy.yaml b/samples/activation/activation-policy.yaml
index 089c574d..6725abce 100644
--- a/samples/activation/activation-policy.yaml
+++ b/samples/activation/activation-policy.yaml
@@ -14,10 +14,8 @@
 # limitations under the License.
 
 # Declares that payment may be scaled to zero and that requests for it should
-# be held rather than failed. Without this the gateway still holds the request
-# — it cannot tell a cold Service from a dead one on its own — but the control
-# plane publishes no scaler metric for the target, so nothing scales up and the
-# request only ever times out.
+# be held rather than failed. Without this, EDS is not rewritten to the
+# Activator and the control plane publishes no scaler metric for the target.
 apiVersion: networking.dubbo.apache.org/v1alpha3
 kind: ServiceActivationPolicy
 metadata:
diff --git a/samples/activation/scaledobject.yaml b/samples/activation/scaledobject.yaml
index 789033eb..7357e35c 100644
--- a/samples/activation/scaledobject.yaml
+++ b/samples/activation/scaledobject.yaml
@@ -43,10 +43,10 @@ spec:
   triggers:
     - type: external
       metadata:
-        # Headless: one address per dubbod pod. The load-balanced Service would
-        # send every query to one replica, which may not be the replica the
-        # gateway reported to.
-        scalerAddress: dubbod-activation-replicas.dubbo-system.svc.cluster.local:26030
+        # Gateways report to every control-plane replica through the headless
+        # replicas Service. KEDA can therefore use the stable load-balanced
+        # Service and receive the same pending count from any replica.
+        scalerAddress: dubbod-activation.dubbo-system.svc.cluster.local:26030
         service: payment
         namespace: activation
         # Pending requests per replica before KEDA adds another. Activation from
diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh
index d10a2a8a..042a7de9 100755
--- a/tests/e2e/run.sh
+++ b/tests/e2e/run.sh
@@ -18,7 +18,7 @@
 # installs the base + dubbod Helm charts, and asserts that the control
 # plane serves xDS state for workloads and ServiceEntry configuration.
 #
-# Requirements: docker, kind, kubectl, helm.
+# Requirements: docker, kind, kubectl, helm, jq.
 #
 # Environment knobs:
 #   CLUSTER_NAME    kind cluster name           (default: dubbo-e2e)
@@ -31,6 +31,9 @@
 #   KEEP_CLUSTER    set to 1 to keep the kind cluster after the run
 #   KIND            path to the kind binary      (default: kind)
 #   KIND_NODE_IMAGE kind node image override     (default: kind release default)
+#   ACTIVATION_E2E  install KEDA and run real scale-to-zero E2E (default: 0)
+#   DXGATE_IMAGE    prebuilt dxgate image used by managed Gateways
+#   KEDA_VERSION    pinned KEDA chart/app version (default: 2.20.2)
 
 set -euo pipefail
 
@@ -48,6 +51,11 @@ UPGRADE_TMP_DIR=""
 PREVIOUS_CHART=""
 KIND="${KIND:-kind}"
 KIND_NODE_IMAGE="${KIND_NODE_IMAGE:-}"
+ACTIVATION_E2E="${ACTIVATION_E2E:-0}"
+DXGATE_IMAGE="${DXGATE_IMAGE:-kdubbo/dxgate:latest}"
+ACTIVATION_APP_IMAGE="${ACTIVATION_APP_IMAGE:-kdubbo/activation-e2e:latest}"
+ACTIVATION_CLIENT_IMAGE="${ACTIVATION_CLIENT_IMAGE:-kdubbo/activation-client:latest}"
+KEDA_VERSION="${KEDA_VERSION:-2.20.2}"
 
 log() { echo "--- $*"; }
 
@@ -57,6 +65,17 @@ fail() {
   "${KUBECTL[@]}" get pods -A -o wide >&2 || true
   echo "--- diagnostics: dubbod logs ---" >&2
   "${KUBECTL[@]}" -n "${SYSTEM_NS}" logs deploy/dubbod --tail=100 >&2 || true
+  echo "--- diagnostics: managed gateway logs ---" >&2
+  local pod
+  while read -r pod; do
+    [[ -n "${pod}" ]] || continue
+    echo "--- ${APP_NS}/${pod} current ---" >&2
+    "${KUBECTL[@]}" -n "${APP_NS}" logs "${pod}" --all-containers --tail=100 >&2 || true
+    echo "--- ${APP_NS}/${pod} previous ---" >&2
+    "${KUBECTL[@]}" -n "${APP_NS}" logs "${pod}" --all-containers --previous --tail=100 >&2 || true
+  done < <("${KUBECTL[@]}" -n "${APP_NS}" get pods \
+    -l gateway.networking.k8s.io/gateway-name \
+    -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)
   exit 1
 }
 
@@ -119,11 +138,33 @@ if ! "${KIND}" get clusters 2>/dev/null | grep -qx "${CLUSTER_NAME}"; then
   "${KIND}" "${KIND_CREATE_ARGS[@]}"
 fi
 
+# A kept cluster is useful for debugging and repeated activation runs. Reset
+# release-scoped state so runtime-mutated webhook fields cannot conflict with
+# the next Helm install after a previous namespace was deleted.
+helm uninstall dubbod --kube-context "kind-${CLUSTER_NAME}" -n "${SYSTEM_NS}" --ignore-not-found >/dev/null 2>&1 || true
+helm uninstall dubbo-base --kube-context "kind-${CLUSTER_NAME}" -n "${SYSTEM_NS}" --ignore-not-found >/dev/null 2>&1 || true
+"${KUBECTL[@]}" delete namespace "${APP_NS}" "${SYSTEM_NS}" --ignore-not-found --wait=true >/dev/null
+"${KUBECTL[@]}" delete validatingwebhookconfiguration,mutatingwebhookconfiguration \
+  -l app=dubbod --ignore-not-found >/dev/null
+
 log "loading ${IMAGE} into kind"
 "${KIND}" load docker-image "${IMAGE}" --name "${CLUSTER_NAME}"
 if [[ "${IMAGE}" != "${UPGRADE_FROM_IMAGE}" ]]; then
   "${KIND}" load docker-image "${UPGRADE_FROM_IMAGE}" --name "${CLUSTER_NAME}"
 fi
+if [[ "${ACTIVATION_E2E}" == "1" ]]; then
+  docker image inspect "${DXGATE_IMAGE}" >/dev/null 2>&1 \
+    || fail "ACTIVATION_E2E requires prebuilt ${DXGATE_IMAGE}"
+  log "building ${ACTIVATION_APP_IMAGE}"
+  docker build -t "${ACTIVATION_APP_IMAGE}" "${ROOT}/tests/e2e/activationapp"
+  docker tag "${IMAGE}" "${ACTIVATION_CLIENT_IMAGE}"
+  log "loading activation data-plane images into kind"
+  "${KIND}" load docker-image \
+    "${DXGATE_IMAGE}" \
+    "${ACTIVATION_APP_IMAGE}" \
+    "${ACTIVATION_CLIENT_IMAGE}" \
+    --name "${CLUSTER_NAME}"
+fi
 
 log "installing Gateway API CRDs"
 # Pin to the sigs.k8s.io/gateway-api version in go.mod. HTTPRoute retry is an
@@ -131,6 +172,20 @@ log "installing Gateway API CRDs"
 GATEWAY_API_VERSION="${GATEWAY_API_VERSION:-v1.4.1}"
 "${KUBECTL[@]}" apply --server-side -f "https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_VERSION}/experimental-install.yaml"
 
+if [[ "${ACTIVATION_E2E}" == "1" ]]; then
+  log "installing KEDA ${KEDA_VERSION}"
+  helm repo add kedacore https://kedacore.github.io/charts --force-update
+  helm repo update kedacore
+  helm upgrade --install keda kedacore/keda \
+    --version "${KEDA_VERSION}" \
+    --kube-context "kind-${CLUSTER_NAME}" \
+    -n keda --create-namespace
+  "${KUBECTL[@]}" -n keda rollout status deploy/keda-operator --timeout=300s \
+    || fail "KEDA operator did not become ready"
+  "${KUBECTL[@]}" -n keda rollout status deploy/keda-admission-webhooks --timeout=300s \
+    || fail "KEDA admission webhook did not become ready"
+fi
+
 log "installing base chart (CRDs)"
 helm upgrade --install dubbo-base "${ROOT}/manifests/charts/base" \
   --kube-context "kind-${CLUSTER_NAME}" \
@@ -146,6 +201,7 @@ install_dubbod() {
     -n "${SYSTEM_NS}" \
     --set global.proxyless.cni.enabled=false \
     --set-string global.proxyless.cni.image="${image}" \
+    --set-string global.gateway.image="${DXGATE_IMAGE}" \
     --set replicaCount="${DUBBOD_REPLICAS}"
 }
 
@@ -285,17 +341,156 @@ retry "Accepted condition on the policy" check_policy_accepted
 log "asserting a managed gateway is told where to report demand"
 "${KUBECTL[@]}" -n "${APP_NS}" apply -f "${ROOT}/tests/e2e/testdata/gateway.yaml" \
   || fail "Gateway was rejected"
-check_gateway_deployment() { "${KUBECTL[@]}" -n "${APP_NS}" get deploy dxgate-gateway >/dev/null; }
+check_gateway_deployment() { "${KUBECTL[@]}" -n "${APP_NS}" get deploy public-dubbo >/dev/null; }
 retry "managed gateway deployment" check_gateway_deployment
-GATEWAY_ENV="$("${KUBECTL[@]}" -n "${APP_NS}" get deploy dxgate-gateway \
+GATEWAY_ENV="$("${KUBECTL[@]}" -n "${APP_NS}" get deploy public-dubbo \
   -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="DXGATE_ACTIVATION_CONTROL_PLANE")].value}')"
 [[ "${GATEWAY_ENV}" == dubbod-activation-replicas.* ]] \
   || fail "gateway reports to '${GATEWAY_ENV}', want the headless activation Service"
 # Reports are attributed per reporter; without a distinct identity two gateway
 # replicas overwrite each other's counts instead of adding to them.
-"${KUBECTL[@]}" -n "${APP_NS}" get deploy dxgate-gateway \
+"${KUBECTL[@]}" -n "${APP_NS}" get deploy public-dubbo \
   -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="POD_NAME")].valueFrom.fieldRef.fieldPath}' \
   | grep -qx metadata.name \
   || fail "gateway does not inject POD_NAME; demand reports would not be attributable to a replica"
 
+if [[ "${ACTIVATION_E2E}" == "1" ]]; then
+  log "asserting multiple Gateways have isolated resources"
+  "${KUBECTL[@]}" -n "${APP_NS}" get deploy dxgate-gateway public-dubbo >/dev/null \
+    || fail "canonical and public Gateway deployments do not coexist"
+  "${KUBECTL[@]}" -n "${APP_NS}" rollout status deploy/dxgate-gateway --timeout=300s \
+    || fail "canonical Activator gateway did not become ready"
+  "${KUBECTL[@]}" -n "${APP_NS}" rollout status deploy/public-dubbo --timeout=300s \
+    || fail "second managed gateway did not become ready"
+
+  log "deploying the proxyless activation target and KEDA ScaledObject"
+  "${KUBECTL[@]}" apply -f "${ROOT}/tests/e2e/testdata/eastwest-activation.yaml"
+  "${KUBECTL[@]}" apply -f "${ROOT}/tests/e2e/testdata/eastwest-activation-scaledobject.yaml"
+  "${KUBECTL[@]}" -n "${APP_NS}" wait --for=condition=Ready scaledobject/payment --timeout=180s \
+    || fail "KEDA ScaledObject did not become ready"
+
+  check_all_activators_have_payment_route() {
+    local pod pods
+    pods="$("${KUBECTL[@]}" -n "${APP_NS}" get pods \
+      -l gateway.networking.k8s.io/gateway-name=dxgate-gateway \
+      --field-selector=status.phase=Running \
+      -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')"
+    [[ "$(wc -w <<<"${pods}")" -ge 2 ]] || return 1
+    while read -r pod; do
+      "${KUBECTL[@]}" get --raw \
+        "/api/v1/namespaces/${APP_NS}/pods/${pod}:26021/proxy/debug/config" \
+        | jq -e '.listeners[]
+          | select(.bind == "0.0.0.0:15080")
+          | .virtual_hosts[]
+          | select(.name == "activation|payment.e2e.svc.cluster.local|8080")' \
+        >/dev/null \
+        || return 1
+    done <<<"${pods}"
+  }
+  retry "all Activator replicas receive the payment activation route" \
+    check_all_activators_have_payment_route
+
+  check_payment_policy_runtime_ready() {
+    local conditions
+    conditions="$("${KUBECTL[@]}" -n "${APP_NS}" get serviceactivationpolicy payment \
+      -o jsonpath='{range .status.conditions[*]}{.type}={.status}{"\n"}{end}')"
+    grep -qx 'ScalerReady=True' <<<"${conditions}" &&
+      grep -qx 'ActivatorReady=True' <<<"${conditions}"
+  }
+  retry "payment policy scaler and Activator readiness" \
+    check_payment_policy_runtime_ready
+
+  check_payment_scaled_to_zero() {
+    [[ "$("${KUBECTL[@]}" -n "${APP_NS}" get deploy payment -o jsonpath='{.spec.replicas}')" == "0" ]]
+  }
+  retry "KEDA scale payment to zero" check_payment_scaled_to_zero
+
+  log "removing one control-plane and one Activator replica before cold demand"
+  "${KUBECTL[@]}" -n "${SYSTEM_NS}" delete pod \
+    "$("${KUBECTL[@]}" -n "${SYSTEM_NS}" get pod -l app=dubbod -o jsonpath='{.items[0].metadata.name}')" \
+    --wait=false
+  "${KUBECTL[@]}" -n "${APP_NS}" delete pod \
+    "$("${KUBECTL[@]}" -n "${APP_NS}" get pod \
+      -l gateway.networking.k8s.io/gateway-name=dxgate-gateway \
+      -o jsonpath='{.items[0].metadata.name}')" \
+    --wait=false
+
+  "${KUBECTL[@]}" -n "${SYSTEM_NS}" rollout status deploy/dubbod --timeout=180s \
+    || fail "control-plane replica did not recover"
+  "${KUBECTL[@]}" -n "${APP_NS}" rollout status deploy/dxgate-gateway --timeout=180s \
+    || fail "Activator replica did not recover"
+  retry "all Activator replicas retain the payment route after failover" \
+    check_all_activators_have_payment_route
+  retry "payment policy remains runtime-ready after HA failover" \
+    check_payment_policy_runtime_ready
+
+  log "sending one proxyless request while payment is at zero"
+  "${KUBECTL[@]}" -n "${APP_NS}" delete pod payment-client --ignore-not-found
+  "${KUBECTL[@]}" apply -f "${ROOT}/tests/e2e/testdata/eastwest-activation-client.yaml"
+  activation_metrics() {
+    local pod
+    while read -r pod; do
+      "${KUBECTL[@]}" get --raw \
+        "/api/v1/namespaces/${APP_NS}/pods/${pod}:26021/proxy/metrics"
+    done < <("${KUBECTL[@]}" -n "${APP_NS}" get pods \
+      -l gateway.networking.k8s.io/gateway-name=dxgate-gateway \
+      -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
+  }
+  activation_payment_requests_total() {
+    activation_metrics \
+      | awk '/^dxgate_http_route_requests_total\{/ && /cluster="outbound\|8080\|\|payment\.e2e\.svc\.cluster\.local"/ { total += $NF } END { print total + 0 }'
+  }
+  # held_requests is an instantaneous gauge and can return to zero before a
+  # polling assertion observes it. The durable proof is the complete sequence:
+  # replicas were zero above, KEDA now scales from pending demand, the request
+  # succeeds, and the Activator route counter increases.
+  check_payment_scaled_from_zero() {
+    [[ "$("${KUBECTL[@]}" -n "${APP_NS}" get deploy payment -o jsonpath='{.spec.replicas}')" -ge 1 ]]
+  }
+  retry "KEDA scale payment from zero" check_payment_scaled_from_zero
+  log "KEDA scaled payment from zero"
+  "${KUBECTL[@]}" -n "${APP_NS}" rollout status deploy/payment --timeout=180s \
+    || fail "payment did not become ready after KEDA activation"
+  "${KUBECTL[@]}" -n "${APP_NS}" wait \
+    --for=jsonpath='{.status.containerStatuses[?(@.name=="client")].state.terminated.exitCode}'=0 \
+    pod/payment-client --timeout=120s \
+    || fail "held proxyless request did not complete after automatic scale-up"
+  check_payment_client_success() {
+    "${KUBECTL[@]}" -n "${APP_NS}" logs payment-client -c client 2>/dev/null | grep -qx payment-ok
+  }
+  retry "cold proxyless response is available in the pod log" \
+    check_payment_client_success
+  COLD_ACTIVATOR_REQUESTS="$(activation_payment_requests_total)"
+  log "cold request completed through Activator (route requests=${COLD_ACTIVATOR_REQUESTS})"
+  [[ "${COLD_ACTIVATOR_REQUESTS}" -ge 1 ]] \
+    || fail "cold request completed without an Activator data-plane metric"
+
+  log "asserting EDS converges back to a direct hot path"
+  # The first ready endpoint and its EDS update are independent events. Keep
+  # one backend alive while polling the data plane, otherwise the short test
+  # cooldown can scale it back to zero before xDS convergence is observable.
+  "${KUBECTL[@]}" -n "${APP_NS}" patch scaledobject payment --type=merge \
+    -p '{"spec":{"minReplicaCount":1}}' >/dev/null
+  hot_request_bypasses_activator() {
+    local before after
+    before="$(activation_payment_requests_total)"
+    "${KUBECTL[@]}" -n "${APP_NS}" delete pod payment-client --ignore-not-found --wait=true >/dev/null
+    "${KUBECTL[@]}" apply -f "${ROOT}/tests/e2e/testdata/eastwest-activation-client.yaml" >/dev/null
+    "${KUBECTL[@]}" -n "${APP_NS}" wait \
+      --for=jsonpath='{.status.containerStatuses[?(@.name=="client")].state.terminated.exitCode}'=0 \
+      pod/payment-client --timeout=30s >/dev/null || return 1
+    check_payment_client_success || return 1
+    after="$(activation_payment_requests_total)"
+    [[ "${after}" == "${before}" ]]
+  }
+  retry "hot proxyless request bypasses the Activator after EDS convergence" \
+    hot_request_bypasses_activator
+  log "hot proxyless request bypassed the Activator"
+
+  "${KUBECTL[@]}" -n "${APP_NS}" patch scaledobject payment --type=merge \
+    -p '{"spec":{"minReplicaCount":0}}' >/dev/null
+  retry "KEDA scale payment back to zero" check_payment_scaled_to_zero
+  log "real KEDA zero-to-one-to-zero activation passed"
+fi
+
 log "e2e smoke test passed"
diff --git a/tests/e2e/testdata/eastwest-activation-scaledobject.yaml b/tests/e2e/testdata/eastwest-activation-scaledobject.yaml
new file mode 100644
index 00000000..5828a6f4
--- /dev/null
+++ b/tests/e2e/testdata/eastwest-activation-scaledobject.yaml
@@ -0,0 +1,39 @@
+# 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.
+
+apiVersion: keda.sh/v1alpha1
+kind: ScaledObject
+metadata:
+  name: payment
+  namespace: e2e
+spec:
+  scaleTargetRef:
+    name: payment
+  minReplicaCount: 0
+  maxReplicaCount: 3
+  pollingInterval: 1
+  cooldownPeriod: 10
+  advanced:
+    horizontalPodAutoscalerConfig:
+      behavior:
+        scaleDown:
+          stabilizationWindowSeconds: 0
+  triggers:
+    - type: external
+      metadata:
+        scalerAddress: dubbod-activation.dubbo-system.svc.cluster.local:26030
+        service: payment
+        namespace: e2e
+        targetPendingRequests: "1"