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

github-actions[bot] 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 1560a5d3 Completed the first task; the initial serverless setup is 
taking shape (#990)
1560a5d3 is described below

commit 1560a5d3353c4848a778b991aa57ce61eb501d5c
Author: mfordjody <[email protected]>
AuthorDate: Fri Aug 7 06:29:06 2026 +0800

    Completed the first task; the initial serverless setup is taking shape 
(#990)
---
 README.md                                          |   2 +
 cni/pkg/nodeagent/iptables.go                      |  11 ++
 cni/pkg/nodeagent/iptables_test.go                 |   1 +
 dubboctl/cmd/analyze.go                            | 146 ++++++++++++++++-
 dubboctl/cmd/analyze_test.go                       | 172 ++++++++++++++++++++
 .../config/kube/gateway/deployment_controller.go   |  37 +++++
 .../kube/gateway/deployment_controller_test.go     | 113 +++++++++++++
 dubbod/discovery/pkg/features/dubbo.go             |   4 +
 manifests/charts/dubbod/files/grpc-engine.yaml     |  14 ++
 manifests/charts/dubbod/files/kube-gateway.yaml    |  50 +++++-
 manifests/charts/dubbod/templates/clusterrole.yaml |  14 ++
 manifests/charts/dubbod/templates/deployment.yaml  |  23 +++
 manifests/charts/dubbod/values.yaml                |  15 +-
 operator/pkg/apis/proto/values_types.proto         |  13 ++
 operator/pkg/apis/values_types.pb.go               | 177 +++++++++++++++------
 operator/pkg/render/manifest_test.go               | 102 ++++++++++++
 pkg/kube/inject/proxyless.go                       |   8 +-
 pkg/kube/inject/proxyless_test.go                  |  39 +++++
 samples/autoscaling/README.md                      |  77 +++++++++
 samples/autoscaling/kafka-consumer.yaml            |  59 +++++++
 samples/autoscaling/scaledobject.yaml              |  51 ++++++
 21 files changed, 1069 insertions(+), 59 deletions(-)

diff --git a/README.md b/README.md
index 1f469895..4459cc06 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,9 @@ Dubbo service mesh enables workloads to join natively, 
receive policies from the
 
 > [!WARNING]
 > The current version is in the **Alpha** phase.
+> 
 > Releases `0.4.6–0.4.9` will be in the **Beta** phase.
+> 
 > Release `0.5.0` will be the first **RC** version.
 
 Dubbo’s control plane provides an abstraction layer over the underlying 
cluster management platform.
diff --git a/cni/pkg/nodeagent/iptables.go b/cni/pkg/nodeagent/iptables.go
index 4c162b07..701acd84 100644
--- a/cni/pkg/nodeagent/iptables.go
+++ b/cni/pkg/nodeagent/iptables.go
@@ -28,6 +28,12 @@ const (
        meshPodIPSet     = "DUBBO-GRPC-INBOUND-PODS"
        meshExcludeIPSet = "DUBBO-GRPC-INBOUND-EXCLUDE"
        dxgateAdminPort  = 26021
+       // dxplaneAdminPort carries the inbound sidecar's health, readiness and
+       // metrics endpoints. kubelet probes it from the host, so the fence has 
to
+       // exempt it or every injected pod fails its readiness probe. Workloads 
that
+       // move the admin listener elsewhere exempt it with the
+       // proxyless.dubbo.apache.org/excludeInboundPorts annotation instead.
+       dxplaneAdminPort = 15020
 )
 
 type CommandRunner interface {
@@ -163,10 +169,12 @@ func (m *IPTablesRuleManager) ensureBase(ctx 
context.Context) error {
        allowExcluded := []string{"-m", "set", "--match-set", meshExcludeIPSet, 
"dst,dst", "-p", "tcp", "-j", "RETURN"}
        allowGRPCInbound := []string{"-m", "set", "--match-set", meshPodIPSet, 
"dst", "-p", "tcp", "--dport", fmt.Sprint(m.grpcInboundPort), "-j", "RETURN"}
        allowDxgateAdmin := []string{"-m", "set", "--match-set", meshPodIPSet, 
"dst", "-p", "tcp", "--dport", fmt.Sprint(dxgateAdminPort), "-j", "RETURN"}
+       allowDxplaneAdmin := []string{"-m", "set", "--match-set", meshPodIPSet, 
"dst", "-p", "tcp", "--dport", fmt.Sprint(dxplaneAdminPort), "-j", "RETURN"}
        rejectOtherTCP := []string{"-m", "set", "--match-set", meshPodIPSet, 
"dst", "-p", "tcp", "-j", "REJECT"}
        m.deleteRepeated(ctx, allowExcluded...)
        m.deleteRepeated(ctx, allowGRPCInbound...)
        m.deleteRepeated(ctx, allowDxgateAdmin...)
+       m.deleteRepeated(ctx, allowDxplaneAdmin...)
        m.deleteRepeated(ctx, rejectOtherTCP...)
        if err := m.appendRule(ctx, allowExcluded...); err != nil {
                return err
@@ -177,6 +185,9 @@ func (m *IPTablesRuleManager) ensureBase(ctx 
context.Context) error {
        if err := m.appendRule(ctx, allowDxgateAdmin...); err != nil {
                return err
        }
+       if err := m.appendRule(ctx, allowDxplaneAdmin...); err != nil {
+               return err
+       }
        return m.appendRule(ctx, rejectOtherTCP...)
 }
 
diff --git a/cni/pkg/nodeagent/iptables_test.go 
b/cni/pkg/nodeagent/iptables_test.go
index dac96d27..e1121391 100644
--- a/cni/pkg/nodeagent/iptables_test.go
+++ b/cni/pkg/nodeagent/iptables_test.go
@@ -43,6 +43,7 @@ func TestIPTablesRuleManagerAddsGRPCInboundBoundaryRules(t 
*testing.T) {
                "-A DUBBO-GRPC-INBOUND -m set --match-set 
DUBBO-GRPC-INBOUND-EXCLUDE dst,dst -p tcp -j RETURN",
                "-A DUBBO-GRPC-INBOUND -m set --match-set 
DUBBO-GRPC-INBOUND-PODS dst -p tcp --dport 15080 -j RETURN",
                "-A DUBBO-GRPC-INBOUND -m set --match-set 
DUBBO-GRPC-INBOUND-PODS dst -p tcp --dport 26021 -j RETURN",
+               "-A DUBBO-GRPC-INBOUND -m set --match-set 
DUBBO-GRPC-INBOUND-PODS dst -p tcp --dport 15020 -j RETURN",
                "-A DUBBO-GRPC-INBOUND -m set --match-set 
DUBBO-GRPC-INBOUND-PODS dst -p tcp -j REJECT",
                "ipset add DUBBO-GRPC-INBOUND-PODS 10.244.0.12 -exist",
                "ipset add DUBBO-GRPC-INBOUND-EXCLUDE 10.244.0.12,tcp:9090 
-exist",
diff --git a/dubboctl/cmd/analyze.go b/dubboctl/cmd/analyze.go
index dc1af1fc..24c6a63b 100644
--- a/dubboctl/cmd/analyze.go
+++ b/dubboctl/cmd/analyze.go
@@ -23,9 +23,12 @@ import (
        "strings"
 
        "github.com/apache/dubbo-kubernetes/dubboctl/pkg/cli"
+       "github.com/apache/dubbo-kubernetes/pkg/config/constants"
        "github.com/apache/dubbo-kubernetes/pkg/kube"
        "github.com/spf13/cobra"
+       appsv1 "k8s.io/api/apps/v1"
        corev1 "k8s.io/api/core/v1"
+       policyv1 "k8s.io/api/policy/v1"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        "k8s.io/apimachinery/pkg/labels"
 )
@@ -54,7 +57,12 @@ func AnalyzeCmd(ctx cli.Context) *cobra.Command {
                Short: "Analyze Dubbo mesh configuration and report potential 
issues",
                Long: `Analyze inspects HTTPRoutes, security policies and 
circuit breaker policies in the
 cluster and reports references to missing services, selectors that match no
-workloads, and authentication configurations that are not actually enforced.`,
+workloads, and authentication configurations that are not actually enforced.
+
+It also checks that the control plane and every gateway survive a node drain:
+more than one replica, a PodDisruptionBudget, and replicas placed on different
+nodes. The control plane is checked even when the analysis is scoped to a
+single application namespace.`,
                Example: `  # Analyze the default namespace
   dubboctl analyze
 
@@ -105,9 +113,145 @@ func runAnalyzers(ctx context.Context, client 
kube.CLIClient, namespace string)
        msgs = append(msgs, analyzeHTTPRoutes(ctx, client, namespace, 
services.Items)...)
        msgs = append(msgs, analyzeSecurityPolicies(ctx, client, namespace, 
pods.Items)...)
        msgs = append(msgs, analyzeCircuitBreakerPolicies(ctx, client, 
namespace, services.Items)...)
+       msgs = append(msgs, collectHighAvailability(ctx, client, namespace)...)
        return msgs, nil
 }
 
+// collectHighAvailability gathers the workloads whose availability the whole
+// mesh depends on. The control plane is always inspected in the system
+// namespace: a request scoped to one application namespace still fails in the
+// same way when dubbod goes down.
+func collectHighAvailability(ctx context.Context, client kube.CLIClient, 
namespace string) []analyzeMessage {
+       namespaces := []string{namespace}
+       if namespace != metav1.NamespaceAll && namespace != 
constants.DubboSystemNamespace {
+               namespaces = append(namespaces, constants.DubboSystemNamespace)
+       }
+
+       msgs := []analyzeMessage{}
+       for _, ns := range namespaces {
+               deployments, err := 
client.Kube().AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{})
+               if err != nil {
+                       msgs = append(msgs, analyzeMessage{levelWarning, 
"Deployment",
+                               fmt.Sprintf("failed to list deployments in %s: 
%v", ns, err)})
+                       continue
+               }
+               budgets, err := 
client.Kube().PolicyV1().PodDisruptionBudgets(ns).List(ctx, 
metav1.ListOptions{})
+               if err != nil {
+                       msgs = append(msgs, analyzeMessage{levelWarning, 
"PodDisruptionBudget",
+                               fmt.Sprintf("failed to list pod disruption 
budgets in %s: %v", ns, err)})
+                       continue
+               }
+               pods, err := client.Kube().CoreV1().Pods(ns).List(ctx, 
metav1.ListOptions{})
+               if err != nil {
+                       msgs = append(msgs, analyzeMessage{levelWarning, "Pod",
+                               fmt.Sprintf("failed to list pods in %s: %v", 
ns, err)})
+                       continue
+               }
+               msgs = append(msgs, analyzeHighAvailability(deployments.Items, 
budgets.Items, pods.Items)...)
+       }
+       return msgs
+}
+
+// meshCriticalRole names a workload whose loss takes the mesh, or a whole
+// gateway's traffic, with it.
+func meshCriticalRole(deployment appsv1.Deployment) string {
+       labels := deployment.Spec.Template.Labels
+       switch {
+       case labels["app"] == "dubbod":
+               return "control plane"
+       case labels["app.kubernetes.io/name"] == "dxgate":
+               return "gateway"
+       default:
+               return ""
+       }
+}
+
+// analyzeHighAvailability reports mesh-critical workloads that a single node
+// drain, upgrade or eviction can take offline. It checks three things that all
+// have to hold together: more than one replica, a disruption budget so
+// voluntary evictions cannot remove them all at once, and pods that actually
+// landed on different nodes.
+func analyzeHighAvailability(deployments []appsv1.Deployment, budgets 
[]policyv1.PodDisruptionBudget, pods []corev1.Pod) []analyzeMessage {
+       msgs := []analyzeMessage{}
+       for _, deployment := range deployments {
+               role := meshCriticalRole(deployment)
+               if role == "" {
+                       continue
+               }
+               resource := fmt.Sprintf("Deployment %s/%s", 
deployment.Namespace, deployment.Name)
+
+               replicas := int32(1)
+               if deployment.Spec.Replicas != nil {
+                       replicas = *deployment.Spec.Replicas
+               }
+               if replicas == 0 {
+                       // Deliberately scaled to zero; availability is not the 
question.
+                       continue
+               }
+               if replicas < 2 {
+                       msgs = append(msgs, analyzeMessage{levelWarning, 
resource,
+                               fmt.Sprintf("%s runs a single replica: any node 
drain, upgrade or eviction takes it offline", role)})
+                       continue
+               }
+
+               selector, err := 
metav1.LabelSelectorAsSelector(deployment.Spec.Selector)
+               if err != nil {
+                       continue
+               }
+               if !hasDisruptionBudget(budgets, deployment.Namespace, 
deployment.Spec.Template.Labels) {
+                       msgs = append(msgs, analyzeMessage{levelWarning, 
resource,
+                               fmt.Sprintf("%s has %d replicas but no 
PodDisruptionBudget: a node drain can evict them all at once", role, replicas)})
+               }
+               if node, single := singleNode(pods, deployment.Namespace, 
selector); single {
+                       msgs = append(msgs, analyzeMessage{levelWarning, 
resource,
+                               fmt.Sprintf("all %s replicas are scheduled on 
node %s: losing it takes the %s down despite the replica count", role, node, 
role)})
+               }
+       }
+       return msgs
+}
+
+func hasDisruptionBudget(budgets []policyv1.PodDisruptionBudget, namespace 
string, podLabels map[string]string) bool {
+       for _, budget := range budgets {
+               if budget.Namespace != namespace || budget.Spec.Selector == nil 
{
+                       continue
+               }
+               selector, err := 
metav1.LabelSelectorAsSelector(budget.Spec.Selector)
+               if err != nil || selector.Empty() {
+                       continue
+               }
+               if selector.Matches(labels.Set(podLabels)) {
+                       return true
+               }
+       }
+       return false
+}
+
+// singleNode reports the node every running replica shares, if they share one.
+// Fewer than two running pods is not evidence of bad placement, so it is not
+// reported.
+func singleNode(pods []corev1.Pod, namespace string, selector labels.Selector) 
(string, bool) {
+       node := ""
+       count := 0
+       for _, pod := range pods {
+               if pod.Namespace != namespace || pod.DeletionTimestamp != nil {
+                       continue
+               }
+               if pod.Status.Phase != corev1.PodRunning || pod.Spec.NodeName 
== "" {
+                       continue
+               }
+               if !selector.Matches(labels.Set(pod.Labels)) {
+                       continue
+               }
+               if node == "" {
+                       node = pod.Spec.NodeName
+               } else if pod.Spec.NodeName != node {
+                       return "", false
+               }
+               count++
+       }
+       return node, count > 1
+}
+
 // analyzeHTTPRoutes reports backendRefs pointing at services or ports that do 
not exist.
 func analyzeHTTPRoutes(ctx context.Context, client kube.CLIClient, namespace 
string, services []corev1.Service) []analyzeMessage {
        msgs := []analyzeMessage{}
diff --git a/dubboctl/cmd/analyze_test.go b/dubboctl/cmd/analyze_test.go
new file mode 100644
index 00000000..3f1024e1
--- /dev/null
+++ b/dubboctl/cmd/analyze_test.go
@@ -0,0 +1,172 @@
+// 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 cmd
+
+import (
+       "strings"
+       "testing"
+
+       appsv1 "k8s.io/api/apps/v1"
+       corev1 "k8s.io/api/core/v1"
+       policyv1 "k8s.io/api/policy/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func controlPlaneDeployment(replicas int32) appsv1.Deployment {
+       return appsv1.Deployment{
+               ObjectMeta: metav1.ObjectMeta{Name: "dubbod", Namespace: 
"dubbo-system"},
+               Spec: appsv1.DeploymentSpec{
+                       Replicas: &replicas,
+                       Selector: &metav1.LabelSelector{MatchLabels: 
map[string]string{"app": "dubbod"}},
+                       Template: corev1.PodTemplateSpec{
+                               ObjectMeta: metav1.ObjectMeta{Labels: 
map[string]string{"app": "dubbod"}},
+                       },
+               },
+       }
+}
+
+func controlPlaneBudget() policyv1.PodDisruptionBudget {
+       return policyv1.PodDisruptionBudget{
+               ObjectMeta: metav1.ObjectMeta{Name: "dubbod", Namespace: 
"dubbo-system"},
+               Spec: policyv1.PodDisruptionBudgetSpec{
+                       Selector: &metav1.LabelSelector{MatchLabels: 
map[string]string{"app": "dubbod"}},
+               },
+       }
+}
+
+func runningPod(name, node string, labels map[string]string) corev1.Pod {
+       return corev1.Pod{
+               ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: 
"dubbo-system", Labels: labels},
+               Spec:       corev1.PodSpec{NodeName: node},
+               Status:     corev1.PodStatus{Phase: corev1.PodRunning},
+       }
+}
+
+func messagesContain(msgs []analyzeMessage, substring string) bool {
+       for _, msg := range msgs {
+               if strings.Contains(msg.Message, substring) {
+                       return true
+               }
+       }
+       return false
+}
+
+func TestAnalyzeHighAvailabilityFlagsSingleReplicaControlPlane(t *testing.T) {
+       msgs := analyzeHighAvailability(
+               []appsv1.Deployment{controlPlaneDeployment(1)},
+               nil,
+               []corev1.Pod{runningPod("dubbod-a", "node-1", 
map[string]string{"app": "dubbod"})},
+       )
+       if len(msgs) != 1 {
+               t.Fatalf("messages = %d, want 1:\n%v", len(msgs), msgs)
+       }
+       if !messagesContain(msgs, "control plane runs a single replica") {
+               t.Fatalf("unexpected message: %v", msgs)
+       }
+}
+
+func TestAnalyzeHighAvailabilityFlagsMissingDisruptionBudget(t *testing.T) {
+       msgs := analyzeHighAvailability(
+               []appsv1.Deployment{controlPlaneDeployment(2)},
+               nil,
+               []corev1.Pod{
+                       runningPod("dubbod-a", "node-1", 
map[string]string{"app": "dubbod"}),
+                       runningPod("dubbod-b", "node-2", 
map[string]string{"app": "dubbod"}),
+               },
+       )
+       if !messagesContain(msgs, "no PodDisruptionBudget") {
+               t.Fatalf("missing disruption budget not reported: %v", msgs)
+       }
+}
+
+// Replica count alone proves nothing: two pods on one node still go down
+// together, which is exactly what the topology spread is meant to prevent.
+func TestAnalyzeHighAvailabilityFlagsReplicasOnOneNode(t *testing.T) {
+       msgs := analyzeHighAvailability(
+               []appsv1.Deployment{controlPlaneDeployment(2)},
+               []policyv1.PodDisruptionBudget{controlPlaneBudget()},
+               []corev1.Pod{
+                       runningPod("dubbod-a", "node-1", 
map[string]string{"app": "dubbod"}),
+                       runningPod("dubbod-b", "node-1", 
map[string]string{"app": "dubbod"}),
+               },
+       )
+       if len(msgs) != 1 {
+               t.Fatalf("messages = %d, want 1:\n%v", len(msgs), msgs)
+       }
+       if !messagesContain(msgs, "scheduled on node node-1") {
+               t.Fatalf("unexpected message: %v", msgs)
+       }
+}
+
+func TestAnalyzeHighAvailabilityAcceptsHealthyControlPlane(t *testing.T) {
+       msgs := analyzeHighAvailability(
+               []appsv1.Deployment{controlPlaneDeployment(2)},
+               []policyv1.PodDisruptionBudget{controlPlaneBudget()},
+               []corev1.Pod{
+                       runningPod("dubbod-a", "node-1", 
map[string]string{"app": "dubbod"}),
+                       runningPod("dubbod-b", "node-2", 
map[string]string{"app": "dubbod"}),
+               },
+       )
+       if len(msgs) != 0 {
+               t.Fatalf("healthy control plane reported %d messages:\n%v", 
len(msgs), msgs)
+       }
+}
+
+func TestAnalyzeHighAvailabilityIgnoresScaledDownAndUnrelatedWorkloads(t 
*testing.T) {
+       zero := int32(0)
+       scaledDown := controlPlaneDeployment(1)
+       scaledDown.Spec.Replicas = &zero
+
+       unrelated := appsv1.Deployment{
+               ObjectMeta: metav1.ObjectMeta{Name: "httpbin", Namespace: 
"backend"},
+               Spec: appsv1.DeploymentSpec{
+                       Selector: &metav1.LabelSelector{MatchLabels: 
map[string]string{"app": "httpbin"}},
+                       Template: corev1.PodTemplateSpec{
+                               ObjectMeta: metav1.ObjectMeta{Labels: 
map[string]string{"app": "httpbin"}},
+                       },
+               },
+       }
+
+       msgs := analyzeHighAvailability([]appsv1.Deployment{scaledDown, 
unrelated}, nil, nil)
+       if len(msgs) != 0 {
+               t.Fatalf("expected no messages, got:\n%v", msgs)
+       }
+}
+
+func TestAnalyzeHighAvailabilityFlagsSingleReplicaGateway(t *testing.T) {
+       replicas := int32(1)
+       gateway := appsv1.Deployment{
+               ObjectMeta: metav1.ObjectMeta{Name: "public-dubbo", Namespace: 
"app"},
+               Spec: appsv1.DeploymentSpec{
+                       Replicas: &replicas,
+                       Selector: &metav1.LabelSelector{MatchLabels: 
map[string]string{
+                               "app.kubernetes.io/name":     "dxgate",
+                               "app.kubernetes.io/instance": "public-dubbo",
+                       }},
+                       Template: corev1.PodTemplateSpec{
+                               ObjectMeta: metav1.ObjectMeta{Labels: 
map[string]string{
+                                       "app.kubernetes.io/name":     "dxgate",
+                                       "app.kubernetes.io/instance": 
"public-dubbo",
+                               }},
+                       },
+               },
+       }
+
+       msgs := analyzeHighAvailability([]appsv1.Deployment{gateway}, nil, nil)
+       if !messagesContain(msgs, "gateway runs a single replica") {
+               t.Fatalf("single replica gateway not reported: %v", msgs)
+       }
+}
diff --git a/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go 
b/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go
index e8bc0bba..7889f56c 100644
--- a/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go
+++ b/dubbod/discovery/pkg/config/kube/gateway/deployment_controller.go
@@ -82,8 +82,15 @@ const (
        xdsAddressAnnotation        = "gateway.dubbo.apache.org/xds-address"
        accessLogAnnotation         = "gateway.dubbo.apache.org/access-log"
        accessLogFormatAnnotation   = 
"gateway.dubbo.apache.org/access-log-format"
+       replicasAnnotation          = "gateway.dubbo.apache.org/replicas"
 )
 
+// defaultGatewayReplicas keeps a gateway serving while one replica is drained,
+// upgraded or evicted. A single replica turns any of those into an outage for
+// every route the gateway fronts. The installed default comes from
+// features.DxgateReplicas; this is the floor used when that value is unusable.
+const defaultGatewayReplicas = 2
+
 var builtinClasses = getBuiltinClasses()
 
 var classInfos = getClassInfos()
@@ -405,6 +412,7 @@ func (d *DeploymentController) configureGateway(log 
*dubbolog.Logger, gw gateway
                ServiceAccount:      defaultName,
                Ports:               ports,
                ServiceType:         serviceType,
+               Replicas:            gatewayReplicas(gw),
                Revision:            d.revision,
                ControllerLabel:     gi.controllerLabel,
                BootstrapConfig:     bootstrapConfig,
@@ -460,6 +468,7 @@ type TemplateInput struct {
        ServiceAccount  string
        Ports           []corev1.ServicePort
        ServiceType     corev1.ServiceType
+       Replicas        int32
        Revision        string
        ControllerLabel string
 
@@ -1410,6 +1419,34 @@ func gatewayServiceNodePort(gw gateway.Gateway) int32 {
        return int32(port)
 }
 
+// gatewayReplicas reports how many dxgate pods to run. Zero is allowed so a
+// gateway can be scaled down deliberately, which is why this does not reuse
+// positiveIntAnnotation.
+func gatewayReplicas(gw gateway.Gateway) int32 {
+       return gatewayReplicasWithDefault(gw, installedGatewayReplicas())
+}
+
+func gatewayReplicasWithDefault(gw gateway.Gateway, fallback int32) int32 {
+       if gw.Annotations == nil || gw.Annotations[replicasAnnotation] == "" {
+               return fallback
+       }
+       replicas, err := strconv.Atoi(gw.Annotations[replicasAnnotation])
+       if err != nil || replicas < 0 {
+               return fallback
+       }
+       return int32(replicas)
+}
+
+// installedGatewayReplicas is the mesh-wide default. A negative setting is
+// meaningless and a zero one would make every gateway default to serving no
+// traffic, so both fall back to the highly available floor.
+func installedGatewayReplicas() int32 {
+       if features.DxgateReplicas < 1 {
+               return defaultGatewayReplicas
+       }
+       return int32(features.DxgateReplicas)
+}
+
 func positiveIntAnnotation(gw gateway.Gateway, name string) (int, bool) {
        if gw.Annotations == nil || gw.Annotations[name] == "" {
                return 0, false
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 ffcfc15e..1101ec89 100644
--- a/dubbod/discovery/pkg/config/kube/gateway/deployment_controller_test.go
+++ b/dubbod/discovery/pkg/config/kube/gateway/deployment_controller_test.go
@@ -501,6 +501,47 @@ func TestServiceTypeForGatewayUsesAnnotation(t *testing.T) 
{
        }
 }
 
+func TestGatewayReplicas(t *testing.T) {
+       tests := []struct {
+               name       string
+               annotation string
+               fallback   int32
+               want       int32
+       }{
+               {name: "unset takes the installed default", fallback: 2, want: 
2},
+               {name: "installed default is honored", fallback: 5, want: 5},
+               {name: "explicit count wins", annotation: "4", fallback: 2, 
want: 4},
+               {name: "zero scales the gateway down deliberately", annotation: 
"0", fallback: 2, want: 0},
+               {name: "negative falls back to the default", annotation: "-1", 
fallback: 2, want: 2},
+               {name: "garbage falls back to the default", annotation: "many", 
fallback: 2, want: 2},
+       }
+
+       for _, test := range tests {
+               t.Run(test.name, func(t *testing.T) {
+                       meta := metav1.ObjectMeta{}
+                       if test.annotation != "" {
+                               meta.Annotations = 
map[string]string{replicasAnnotation: test.annotation}
+                       }
+                       got := 
gatewayReplicasWithDefault(gatewayv1.Gateway{ObjectMeta: meta}, test.fallback)
+                       if got != test.want {
+                               t.Fatalf("gatewayReplicasWithDefault() = %d, 
want %d", got, test.want)
+                       }
+               })
+       }
+}
+
+// TestInstalledGatewayReplicasRejectsUnusableSettings guards the mesh-wide
+// default: zero would make every gateway default to serving no traffic at all,
+// which is never what an operator means by "the default".
+func TestInstalledGatewayReplicasRejectsUnusableSettings(t *testing.T) {
+       if got := installedGatewayReplicas(); got < 1 {
+               t.Fatalf("installedGatewayReplicas() = %d, want at least 1", 
got)
+       }
+       if got := gatewayReplicas(gatewayv1.Gateway{}); got < 1 {
+               t.Fatalf("gatewayReplicas() with no annotation = %d, want at 
least 1", got)
+       }
+}
+
 func TestObservabilityConfigForGatewayDefaults(t *testing.T) {
        cfg := resolveGatewayObservability(gatewayv1.Gateway{
                ObjectMeta: metav1.ObjectMeta{Name: "public", Namespace: "app"},
@@ -702,6 +743,78 @@ func TestKubeGatewayTemplateRendersDxgateResources(t 
*testing.T) {
        }
 }
 
+// TestKubeGatewayTemplateRendersHighAvailabilityResources covers the path a
+// real reconcile takes: gatewayReplicas defaults to 2, which must produce a
+// disruption budget and spread the pods. At one replica the budget has to be
+// omitted, or minAvailable 1 blocks every voluntary eviction of that pod.
+func TestKubeGatewayTemplateRendersHighAvailabilityResources(t *testing.T) {
+       templatePath := filepath.Join("..", "..", "..", "..", "..", "..", 
"manifests", "charts", "dubbod", "files", "kube-gateway.yaml")
+       raw, err := os.ReadFile(templatePath)
+       if err != nil {
+               t.Fatal(err)
+       }
+       templates, err := inject.ParseTemplates(inject.RawTemplates{"gateway": 
string(raw)})
+       if err != nil {
+               t.Fatal(err)
+       }
+       controller := &DeploymentController{
+               injectConfig: func() inject.Config {
+                       return inject.Config{Templates: templates}
+               },
+       }
+       input := TemplateInput{
+               Gateway: &gatewayv1.Gateway{
+                       ObjectMeta: metav1.ObjectMeta{Name: "public", 
Namespace: "app"},
+               },
+               DeploymentName:      "public-dubbo",
+               ServiceAccount:      "public-dubbo",
+               Ports:               []corev1.ServicePort{{Name: "http", Port: 
80, TargetPort: intstr.FromInt(15080)}},
+               ServiceType:         corev1.ServiceTypeLoadBalancer,
+               Replicas:            gatewayReplicas(gatewayv1.Gateway{}),
+               Revision:            "default",
+               BootstrapConfig:     "{}\n",
+               BootstrapConfigHash: "abc123",
+               DxgateImage:         "kdubbo/dxgate:test",
+       }
+
+       rendered, err := controller.render("gateway", input)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if len(rendered) != 5 {
+               t.Fatalf("rendered %d resources, want 5 including the 
disruption budget", len(rendered))
+       }
+       joined := strings.Join(rendered, "\n---\n")
+       for _, want := range []string{
+               "replicas: 2",
+               "kind: PodDisruptionBudget",
+               "minAvailable: 1",
+               "podAntiAffinity",
+               "topologySpreadConstraints",
+               "whenUnsatisfiable: ScheduleAnyway",
+       } {
+               if !strings.Contains(joined, want) {
+                       t.Fatalf("high availability output missing %q:\n%s", 
want, joined)
+               }
+       }
+
+       input.Replicas = 1
+       rendered, err = controller.render("gateway", input)
+       if err != nil {
+               t.Fatal(err)
+       }
+       if len(rendered) != 4 {
+               t.Fatalf("rendered %d resources at one replica, want 4 without 
a disruption budget", len(rendered))
+       }
+       joined = strings.Join(rendered, "\n---\n")
+       if strings.Contains(joined, "kind: PodDisruptionBudget") {
+               t.Fatalf("disruption budget rendered for a single 
replica:\n%s", joined)
+       }
+       if strings.Contains(joined, "topologySpreadConstraints") {
+               t.Fatalf("topology spread rendered for a single replica:\n%s", 
joined)
+       }
+}
+
 func TestResolveGatewayObservabilityTelemetryHierarchy(t *testing.T) {
        resources := []telemetryconfig.Resource{
                {
diff --git a/dubbod/discovery/pkg/features/dubbo.go 
b/dubbod/discovery/pkg/features/dubbo.go
index 8dfa92d1..c455dc8d 100644
--- a/dubbod/discovery/pkg/features/dubbo.go
+++ b/dubbod/discovery/pkg/features/dubbo.go
@@ -63,6 +63,10 @@ var (
                "Name of the default GatewayClass").Get()
        DxgateImage = env.Register("DUBBO_DXGATE_IMAGE", "kdubbo/dxgate:latest",
                "Container image used for managed Dubbo Gateway API data-plane 
deployments").Get()
+       DxgateReplicas = env.Register("DUBBO_DXGATE_REPLICAS", 2,
+               "Default replica count for managed Dubbo Gateway API data-plane 
deployments. Two or more keeps a"+
+                       " gateway serving while one replica is drained; 
individual gateways override it with the"+
+                       " gateway.dubbo.apache.org/replicas annotation").Get()
        StatusMaxWorkers = env.Register("DUBBO_STATUS_MAX_WORKERS", 100, "The 
maximum number of workers"+
                " for status update").Get()
 )
diff --git a/manifests/charts/dubbod/files/grpc-engine.yaml 
b/manifests/charts/dubbod/files/grpc-engine.yaml
index 8a4d9120..f714a8a3 100644
--- a/manifests/charts/dubbod/files/grpc-engine.yaml
+++ b/manifests/charts/dubbod/files/grpc-engine.yaml
@@ -120,6 +120,20 @@ spec:
     - name: grpc-inbound
       containerPort: 15080
       protocol: TCP
+    - name: dxplane-admin
+      containerPort: 15020
+      protocol: TCP
+    # Failing readiness is what withdraws this pod from its EndpointSlice while
+    # the listener is still up, so callers whose EDS has not caught up keep
+    # succeeding through the drain. periodSeconds x failureThreshold must stay
+    # below the sidecar's termination drain delay (5s by default), or the pod
+    # closes its listener before kubelet has noticed it is going away.
+    readinessProbe:
+      httpGet:
+        path: /readyz
+        port: 15020
+      periodSeconds: 2
+      failureThreshold: 2
     volumeMounts:
     - name: dubbo-xds
       mountPath: /etc/dubbo/proxy
diff --git a/manifests/charts/dubbod/files/kube-gateway.yaml 
b/manifests/charts/dubbod/files/kube-gateway.yaml
index f89a2b9e..23b44ad8 100644
--- a/manifests/charts/dubbod/files/kube-gateway.yaml
+++ b/manifests/charts/dubbod/files/kube-gateway.yaml
@@ -50,7 +50,7 @@ metadata:
     app.kubernetes.io/managed-by: dubbod
     gateway.networking.k8s.io/gateway-name: {{ .Gateway.Name }}
 spec:
-  replicas: 1
+  replicas: {{ .Replicas }}
   selector:
     matchLabels:
       app.kubernetes.io/name: dxgate
@@ -73,6 +73,35 @@ spec:
         prometheus.io/port: "26021"
     spec:
       serviceAccountName: {{ .ServiceAccount }}
+{{- if gt (int .Replicas) 1 }}
+      affinity:
+        podAntiAffinity:
+          preferredDuringSchedulingIgnoredDuringExecution:
+            - weight: 100
+              podAffinityTerm:
+                topologyKey: kubernetes.io/hostname
+                labelSelector:
+                  matchLabels:
+                    app.kubernetes.io/name: dxgate
+                    app.kubernetes.io/instance: {{ .DeploymentName }}
+      # Advisory spread: a single-node or single-zone cluster still schedules
+      # every replica rather than leaving the gateway short.
+      topologySpreadConstraints:
+        - maxSkew: 1
+          topologyKey: topology.kubernetes.io/zone
+          whenUnsatisfiable: ScheduleAnyway
+          labelSelector:
+            matchLabels:
+              app.kubernetes.io/name: dxgate
+              app.kubernetes.io/instance: {{ .DeploymentName }}
+        - maxSkew: 1
+          topologyKey: kubernetes.io/hostname
+          whenUnsatisfiable: ScheduleAnyway
+          labelSelector:
+            matchLabels:
+              app.kubernetes.io/name: dxgate
+              app.kubernetes.io/instance: {{ .DeploymentName }}
+{{- end }}
       containers:
       - name: dxgate
         image: {{ .DxgateImage }}
@@ -188,3 +217,22 @@ spec:
 {{- end }}
     protocol: {{ .Protocol | default "TCP" }}
 {{- end }}
+{{- if gt (int .Replicas) 1 }}
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: {{ .DeploymentName }}
+  namespace: {{ .Gateway.Namespace }}
+  labels:
+    app.kubernetes.io/name: dxgate
+    app.kubernetes.io/instance: {{ .DeploymentName }}
+    app.kubernetes.io/managed-by: dubbod
+    gateway.networking.k8s.io/gateway-name: {{ .Gateway.Name }}
+spec:
+  minAvailable: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: dxgate
+      app.kubernetes.io/instance: {{ .DeploymentName }}
+{{- end }}
diff --git a/manifests/charts/dubbod/templates/clusterrole.yaml 
b/manifests/charts/dubbod/templates/clusterrole.yaml
index 16f4c5c9..04339413 100644
--- a/manifests/charts/dubbod/templates/clusterrole.yaml
+++ b/manifests/charts/dubbod/templates/clusterrole.yaml
@@ -111,6 +111,20 @@ rules:
       - update
       - patch
       - delete
+  # The gateway controller renders a PodDisruptionBudget alongside each
+  # multi-replica dxgate Deployment. Without this the budget is generated but
+  # never applied, and the apply failure is the only symptom.
+  - apiGroups: ["policy"]
+    resources:
+      - poddisruptionbudgets
+    verbs:
+      - get
+      - list
+      - watch
+      - create
+      - update
+      - patch
+      - delete
   - apiGroups: [""]
     resources:
       - services
diff --git a/manifests/charts/dubbod/templates/deployment.yaml 
b/manifests/charts/dubbod/templates/deployment.yaml
index 69fae644..f11dc0f4 100644
--- a/manifests/charts/dubbod/templates/deployment.yaml
+++ b/manifests/charts/dubbod/templates/deployment.yaml
@@ -30,6 +30,9 @@
 {{- $eastWestGateway := $multicluster.eastWestGateway | default dict }}
 {{- $managementPort := int (coalesce $management.port $defaultManagement.port 
26080) }}
 {{- $replicaCount := int (coalesce .Values.replicaCount $defaults.replicaCount 
1) }}
+{{- $gateway := $global.gateway | default dict }}
+{{- $defaultGateway := $defaultGlobal.gateway | default dict }}
+{{- $gatewayReplicas := int (coalesce $gateway.replicaCount 
$defaultGateway.replicaCount 2) }}
 {{- $remoteAccessCertificateHosts := $remoteAccess.certificateHosts | default 
$defaultRemoteAccess.certificateHosts | default (list) }}
 {{- $eastWestGateways := $eastWestGateway.gateways | default 
$defaultEastWestGateway.gateways | default (list) }}
 {{- $eastWestGatewayEntries := list }}
@@ -67,6 +70,24 @@ spec:
                 labelSelector:
                   matchLabels:
                     app: dubbod
+{{- if gt $replicaCount 1 }}
+      # ScheduleAnyway keeps the spread advisory: a single-node or single-zone
+      # cluster still schedules every replica instead of leaving the control
+      # plane one pod short.
+      topologySpreadConstraints:
+        - maxSkew: 1
+          topologyKey: topology.kubernetes.io/zone
+          whenUnsatisfiable: ScheduleAnyway
+          labelSelector:
+            matchLabels:
+              app: dubbod
+        - maxSkew: 1
+          topologyKey: kubernetes.io/hostname
+          whenUnsatisfiable: ScheduleAnyway
+          labelSelector:
+            matchLabels:
+              app: dubbod
+{{- end }}
       containers:
         - name: execute
           image: {{ $image | quote }}
@@ -106,6 +127,8 @@ spec:
             - name: DUBBO_EASTWEST_GATEWAYS
               value: {{ join "," $eastWestGatewayEntries | quote }}
 {{- end }}
+            - name: DUBBO_DXGATE_REPLICAS
+              value: {{ $gatewayReplicas | quote }}
             - name: CLUSTER_ID
               value: "Kubernetes"
             - name: POD_NAME
diff --git a/manifests/charts/dubbod/values.yaml 
b/manifests/charts/dubbod/values.yaml
index a0dc0ebf..b5731aff 100644
--- a/manifests/charts/dubbod/values.yaml
+++ b/manifests/charts/dubbod/values.yaml
@@ -16,9 +16,12 @@
 _internal_default_values_not_set:
   revision: ""
 
-  # Number of dubbod replicas. Set >= 2 for a highly available control plane;
-  # a PodDisruptionBudget (minAvailable: 1) is created automatically then.
-  replicaCount: 1
+  # Number of dubbod replicas. Two by default: a single replica makes every
+  # node drain, upgrade or eviction a full control-plane outage, during which
+  # no workload can be injected and no xDS update is served. At >= 2 a
+  # PodDisruptionBudget (minAvailable: 1) and topology spread are added
+  # automatically. Set to 1 only for local development.
+  replicaCount: 2
 
   global:
     proxy:
@@ -45,6 +48,12 @@ _internal_default_values_not_set:
     management:
       port: 26080
 
+    gateway:
+      # Default replica count for every managed dxgate Deployment. An
+      # individual Gateway overrides it with the
+      # gateway.dubbo.apache.org/replicas annotation.
+      replicaCount: 2
+
     configValidation: true
 
     multicluster:
diff --git a/operator/pkg/apis/proto/values_types.proto 
b/operator/pkg/apis/proto/values_types.proto
index d008ae6a..bcbeec79 100644
--- a/operator/pkg/apis/proto/values_types.proto
+++ b/operator/pkg/apis/proto/values_types.proto
@@ -60,6 +60,12 @@ message MeshCNIConfig {
   string refreshInterval = 11;
 }
 
+message GatewayConfig {
+  // Default replica count for managed dxgate deployments. Individual Gateways
+  // override it with the gateway.dubbo.apache.org/replicas annotation.
+  google.protobuf.Int32Value replicaCount = 1;
+}
+
 message GlobalConfig {
   ProxyConfig proxy = 1;
 
@@ -72,6 +78,8 @@ message GlobalConfig {
   MulticlusterConfig multicluster = 5;
 
   ProxylessConfig proxyless = 6;
+
+  GatewayConfig gateway = 7;
 }
 
 message RemoteAccessConfig {
@@ -124,6 +132,11 @@ message Values {
 
   // Revision for rendered control plane resources.
   string revision = 2;
+
+  // Number of dubbod replicas. Two by default; a PodDisruptionBudget and
+  // topology spread are added automatically above one. Wrapped so an explicit
+  // 1 is distinguishable from an unset field.
+  google.protobuf.Int32Value replicaCount = 3;
 }
 
 // IntOrString is a type that can hold an int32 or a string.  When used in
diff --git a/operator/pkg/apis/values_types.pb.go 
b/operator/pkg/apis/values_types.pb.go
index a20ca413..03c82c28 100644
--- a/operator/pkg/apis/values_types.pb.go
+++ b/operator/pkg/apis/values_types.pb.go
@@ -294,6 +294,52 @@ func (x *MeshCNIConfig) GetRefreshInterval() string {
        return ""
 }
 
+type GatewayConfig struct {
+       state protoimpl.MessageState `protogen:"open.v1"`
+       // Default replica count for managed dxgate deployments. Individual 
Gateways
+       // override it with the gateway.dubbo.apache.org/replicas annotation.
+       ReplicaCount  *wrapperspb.Int32Value 
`protobuf:"bytes,1,opt,name=replicaCount,proto3" json:"replicaCount,omitempty"`
+       unknownFields protoimpl.UnknownFields
+       sizeCache     protoimpl.SizeCache
+}
+
+func (x *GatewayConfig) Reset() {
+       *x = GatewayConfig{}
+       mi := &file_values_types_proto_msgTypes[4]
+       ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+       ms.StoreMessageInfo(mi)
+}
+
+func (x *GatewayConfig) String() string {
+       return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GatewayConfig) ProtoMessage() {}
+
+func (x *GatewayConfig) ProtoReflect() protoreflect.Message {
+       mi := &file_values_types_proto_msgTypes[4]
+       if x != nil {
+               ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+               if ms.LoadMessageInfo() == nil {
+                       ms.StoreMessageInfo(mi)
+               }
+               return ms
+       }
+       return mi.MessageOf(x)
+}
+
+// Deprecated: Use GatewayConfig.ProtoReflect.Descriptor instead.
+func (*GatewayConfig) Descriptor() ([]byte, []int) {
+       return file_values_types_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *GatewayConfig) GetReplicaCount() *wrapperspb.Int32Value {
+       if x != nil {
+               return x.ReplicaCount
+       }
+       return nil
+}
+
 type GlobalConfig struct {
        state            protoimpl.MessageState `protogen:"open.v1"`
        Proxy            *ProxyConfig           
`protobuf:"bytes,1,opt,name=proxy,proto3" json:"proxy,omitempty"`
@@ -302,13 +348,14 @@ type GlobalConfig struct {
        ConfigValidation bool                   
`protobuf:"varint,4,opt,name=configValidation,proto3" 
json:"configValidation,omitempty"`
        Multicluster     *MulticlusterConfig    
`protobuf:"bytes,5,opt,name=multicluster,proto3" json:"multicluster,omitempty"`
        Proxyless        *ProxylessConfig       
`protobuf:"bytes,6,opt,name=proxyless,proto3" json:"proxyless,omitempty"`
+       Gateway          *GatewayConfig         
`protobuf:"bytes,7,opt,name=gateway,proto3" json:"gateway,omitempty"`
        unknownFields    protoimpl.UnknownFields
        sizeCache        protoimpl.SizeCache
 }
 
 func (x *GlobalConfig) Reset() {
        *x = GlobalConfig{}
-       mi := &file_values_types_proto_msgTypes[4]
+       mi := &file_values_types_proto_msgTypes[5]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
 }
@@ -320,7 +367,7 @@ func (x *GlobalConfig) String() string {
 func (*GlobalConfig) ProtoMessage() {}
 
 func (x *GlobalConfig) ProtoReflect() protoreflect.Message {
-       mi := &file_values_types_proto_msgTypes[4]
+       mi := &file_values_types_proto_msgTypes[5]
        if x != nil {
                ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
                if ms.LoadMessageInfo() == nil {
@@ -333,7 +380,7 @@ func (x *GlobalConfig) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GlobalConfig.ProtoReflect.Descriptor instead.
 func (*GlobalConfig) Descriptor() ([]byte, []int) {
-       return file_values_types_proto_rawDescGZIP(), []int{4}
+       return file_values_types_proto_rawDescGZIP(), []int{5}
 }
 
 func (x *GlobalConfig) GetProxy() *ProxyConfig {
@@ -378,6 +425,13 @@ func (x *GlobalConfig) GetProxyless() *ProxylessConfig {
        return nil
 }
 
+func (x *GlobalConfig) GetGateway() *GatewayConfig {
+       if x != nil {
+               return x.Gateway
+       }
+       return nil
+}
+
 type RemoteAccessConfig struct {
        state            protoimpl.MessageState `protogen:"open.v1"`
        Enabled          bool                   
`protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
@@ -392,7 +446,7 @@ type RemoteAccessConfig struct {
 
 func (x *RemoteAccessConfig) Reset() {
        *x = RemoteAccessConfig{}
-       mi := &file_values_types_proto_msgTypes[5]
+       mi := &file_values_types_proto_msgTypes[6]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
 }
@@ -404,7 +458,7 @@ func (x *RemoteAccessConfig) String() string {
 func (*RemoteAccessConfig) ProtoMessage() {}
 
 func (x *RemoteAccessConfig) ProtoReflect() protoreflect.Message {
-       mi := &file_values_types_proto_msgTypes[5]
+       mi := &file_values_types_proto_msgTypes[6]
        if x != nil {
                ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
                if ms.LoadMessageInfo() == nil {
@@ -417,7 +471,7 @@ func (x *RemoteAccessConfig) ProtoReflect() 
protoreflect.Message {
 
 // Deprecated: Use RemoteAccessConfig.ProtoReflect.Descriptor instead.
 func (*RemoteAccessConfig) Descriptor() ([]byte, []int) {
-       return file_values_types_proto_rawDescGZIP(), []int{5}
+       return file_values_types_proto_rawDescGZIP(), []int{6}
 }
 
 func (x *RemoteAccessConfig) GetEnabled() bool {
@@ -473,7 +527,7 @@ type EastWestGatewayEndpoint struct {
 
 func (x *EastWestGatewayEndpoint) Reset() {
        *x = EastWestGatewayEndpoint{}
-       mi := &file_values_types_proto_msgTypes[6]
+       mi := &file_values_types_proto_msgTypes[7]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
 }
@@ -485,7 +539,7 @@ func (x *EastWestGatewayEndpoint) String() string {
 func (*EastWestGatewayEndpoint) ProtoMessage() {}
 
 func (x *EastWestGatewayEndpoint) ProtoReflect() protoreflect.Message {
-       mi := &file_values_types_proto_msgTypes[6]
+       mi := &file_values_types_proto_msgTypes[7]
        if x != nil {
                ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
                if ms.LoadMessageInfo() == nil {
@@ -498,7 +552,7 @@ func (x *EastWestGatewayEndpoint) ProtoReflect() 
protoreflect.Message {
 
 // Deprecated: Use EastWestGatewayEndpoint.ProtoReflect.Descriptor instead.
 func (*EastWestGatewayEndpoint) Descriptor() ([]byte, []int) {
-       return file_values_types_proto_rawDescGZIP(), []int{6}
+       return file_values_types_proto_rawDescGZIP(), []int{7}
 }
 
 func (x *EastWestGatewayEndpoint) GetClusterName() string {
@@ -537,7 +591,7 @@ type EastWestGatewayConfig struct {
 
 func (x *EastWestGatewayConfig) Reset() {
        *x = EastWestGatewayConfig{}
-       mi := &file_values_types_proto_msgTypes[7]
+       mi := &file_values_types_proto_msgTypes[8]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
 }
@@ -549,7 +603,7 @@ func (x *EastWestGatewayConfig) String() string {
 func (*EastWestGatewayConfig) ProtoMessage() {}
 
 func (x *EastWestGatewayConfig) ProtoReflect() protoreflect.Message {
-       mi := &file_values_types_proto_msgTypes[7]
+       mi := &file_values_types_proto_msgTypes[8]
        if x != nil {
                ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
                if ms.LoadMessageInfo() == nil {
@@ -562,7 +616,7 @@ func (x *EastWestGatewayConfig) ProtoReflect() 
protoreflect.Message {
 
 // Deprecated: Use EastWestGatewayConfig.ProtoReflect.Descriptor instead.
 func (*EastWestGatewayConfig) Descriptor() ([]byte, []int) {
-       return file_values_types_proto_rawDescGZIP(), []int{7}
+       return file_values_types_proto_rawDescGZIP(), []int{8}
 }
 
 func (x *EastWestGatewayConfig) GetEnabled() bool {
@@ -624,7 +678,7 @@ type MulticlusterConfig struct {
 
 func (x *MulticlusterConfig) Reset() {
        *x = MulticlusterConfig{}
-       mi := &file_values_types_proto_msgTypes[8]
+       mi := &file_values_types_proto_msgTypes[9]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
 }
@@ -636,7 +690,7 @@ func (x *MulticlusterConfig) String() string {
 func (*MulticlusterConfig) ProtoMessage() {}
 
 func (x *MulticlusterConfig) ProtoReflect() protoreflect.Message {
-       mi := &file_values_types_proto_msgTypes[8]
+       mi := &file_values_types_proto_msgTypes[9]
        if x != nil {
                ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
                if ms.LoadMessageInfo() == nil {
@@ -649,7 +703,7 @@ func (x *MulticlusterConfig) ProtoReflect() 
protoreflect.Message {
 
 // Deprecated: Use MulticlusterConfig.ProtoReflect.Descriptor instead.
 func (*MulticlusterConfig) Descriptor() ([]byte, []int) {
-       return file_values_types_proto_rawDescGZIP(), []int{8}
+       return file_values_types_proto_rawDescGZIP(), []int{9}
 }
 
 func (x *MulticlusterConfig) GetRemoteAccess() *RemoteAccessConfig {
@@ -671,14 +725,18 @@ type Values struct {
        // Global configuration for dubbo components.
        Global *GlobalConfig `protobuf:"bytes,1,opt,name=global,proto3" 
json:"global,omitempty"`
        // Revision for rendered control plane resources.
-       Revision      string `protobuf:"bytes,2,opt,name=revision,proto3" 
json:"revision,omitempty"`
+       Revision string `protobuf:"bytes,2,opt,name=revision,proto3" 
json:"revision,omitempty"`
+       // Number of dubbod replicas. Two by default; a PodDisruptionBudget and
+       // topology spread are added automatically above one. Wrapped so an 
explicit
+       // 1 is distinguishable from an unset field.
+       ReplicaCount  *wrapperspb.Int32Value 
`protobuf:"bytes,3,opt,name=replicaCount,proto3" json:"replicaCount,omitempty"`
        unknownFields protoimpl.UnknownFields
        sizeCache     protoimpl.SizeCache
 }
 
 func (x *Values) Reset() {
        *x = Values{}
-       mi := &file_values_types_proto_msgTypes[9]
+       mi := &file_values_types_proto_msgTypes[10]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
 }
@@ -690,7 +748,7 @@ func (x *Values) String() string {
 func (*Values) ProtoMessage() {}
 
 func (x *Values) ProtoReflect() protoreflect.Message {
-       mi := &file_values_types_proto_msgTypes[9]
+       mi := &file_values_types_proto_msgTypes[10]
        if x != nil {
                ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
                if ms.LoadMessageInfo() == nil {
@@ -703,7 +761,7 @@ func (x *Values) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use Values.ProtoReflect.Descriptor instead.
 func (*Values) Descriptor() ([]byte, []int) {
-       return file_values_types_proto_rawDescGZIP(), []int{9}
+       return file_values_types_proto_rawDescGZIP(), []int{10}
 }
 
 func (x *Values) GetGlobal() *GlobalConfig {
@@ -720,6 +778,13 @@ func (x *Values) GetRevision() string {
        return ""
 }
 
+func (x *Values) GetReplicaCount() *wrapperspb.Int32Value {
+       if x != nil {
+               return x.ReplicaCount
+       }
+       return nil
+}
+
 // IntOrString is a type that can hold an int32 or a string.  When used in
 // JSON or YAML marshalling and unmarshalling, it produces or consumes the
 // inner type.  This allows you to have, for example, a JSON field that can
@@ -740,7 +805,7 @@ type IntOrString struct {
 
 func (x *IntOrString) Reset() {
        *x = IntOrString{}
-       mi := &file_values_types_proto_msgTypes[10]
+       mi := &file_values_types_proto_msgTypes[11]
        ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
        ms.StoreMessageInfo(mi)
 }
@@ -752,7 +817,7 @@ func (x *IntOrString) String() string {
 func (*IntOrString) ProtoMessage() {}
 
 func (x *IntOrString) ProtoReflect() protoreflect.Message {
-       mi := &file_values_types_proto_msgTypes[10]
+       mi := &file_values_types_proto_msgTypes[11]
        if x != nil {
                ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
                if ms.LoadMessageInfo() == nil {
@@ -765,7 +830,7 @@ func (x *IntOrString) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use IntOrString.ProtoReflect.Descriptor instead.
 func (*IntOrString) Descriptor() ([]byte, []int) {
-       return file_values_types_proto_rawDescGZIP(), []int{10}
+       return file_values_types_proto_rawDescGZIP(), []int{11}
 }
 
 func (x *IntOrString) GetType() int64 {
@@ -812,7 +877,9 @@ const file_values_types_proto_rawDesc = "" +
        "\fiptablesPath\x18\t \x01(\tR\fiptablesPath\x12\x1c\n" +
        "\tipsetPath\x18\n" +
        " \x01(\tR\tipsetPath\x12(\n" +
-       "\x0frefreshInterval\x18\v \x01(\tR\x0frefreshInterval\"\xfa\x02\n" +
+       "\x0frefreshInterval\x18\v \x01(\tR\x0frefreshInterval\"P\n" +
+       "\rGatewayConfig\x12?\n" +
+       "\freplicaCount\x18\x01 
\x01(\v2\x1b.google.protobuf.Int32ValueR\freplicaCount\"\xbc\x03\n" +
        "\fGlobalConfig\x12:\n" +
        "\x05proxy\x18\x01 
\x01(\v2$.dubbo.operator.v1alpha1.ProxyConfigR\x05proxy\x12\x1e\n" +
        "\n" +
@@ -823,7 +890,8 @@ const file_values_types_proto_rawDesc = "" +
        "management\x12*\n" +
        "\x10configValidation\x18\x04 \x01(\bR\x10configValidation\x12O\n" +
        "\fmulticluster\x18\x05 
\x01(\v2+.dubbo.operator.v1alpha1.MulticlusterConfigR\fmulticluster\x12F\n" +
-       "\tproxyless\x18\x06 
\x01(\v2(.dubbo.operator.v1alpha1.ProxylessConfigR\tproxyless\"\xd4\x01\n" +
+       "\tproxyless\x18\x06 
\x01(\v2(.dubbo.operator.v1alpha1.ProxylessConfigR\tproxyless\x12@\n" +
+       "\agateway\x18\a 
\x01(\v2&.dubbo.operator.v1alpha1.GatewayConfigR\agateway\"\xd4\x01\n" +
        "\x12RemoteAccessConfig\x12\x18\n" +
        "\aenabled\x18\x01 \x01(\bR\aenabled\x12 \n" +
        "\vserviceType\x18\x02 \x01(\tR\vserviceType\x12\x18\n" +
@@ -849,10 +917,11 @@ const file_values_types_proto_rawDesc = "" +
        "xdsAddress\"\xbf\x01\n" +
        "\x12MulticlusterConfig\x12O\n" +
        "\fremoteAccess\x18\x01 
\x01(\v2+.dubbo.operator.v1alpha1.RemoteAccessConfigR\fremoteAccess\x12X\n" +
-       "\x0feastWestGateway\x18\x02 
\x01(\v2..dubbo.operator.v1alpha1.EastWestGatewayConfigR\x0feastWestGateway\"c\n"
 +
+       "\x0feastWestGateway\x18\x02 
\x01(\v2..dubbo.operator.v1alpha1.EastWestGatewayConfigR\x0feastWestGateway\"\xa4\x01\n"
 +
        "\x06Values\x12=\n" +
        "\x06global\x18\x01 
\x01(\v2%.dubbo.operator.v1alpha1.GlobalConfigR\x06global\x12\x1a\n" +
-       "\brevision\x18\x02 \x01(\tR\brevision\"\x8c\x01\n" +
+       "\brevision\x18\x02 \x01(\tR\brevision\x12?\n" +
+       "\freplicaCount\x18\x03 
\x01(\v2\x1b.google.protobuf.Int32ValueR\freplicaCount\"\x8c\x01\n" +
        "\vIntOrString\x12\x12\n" +
        "\x04type\x18\x01 \x01(\x03R\x04type\x123\n" +
        "\x06intVal\x18\x02 
\x01(\v2\x1b.google.protobuf.Int32ValueR\x06intVal\x124\n" +
@@ -870,39 +939,43 @@ func file_values_types_proto_rawDescGZIP() []byte {
        return file_values_types_proto_rawDescData
 }
 
-var file_values_types_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
+var file_values_types_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
 var file_values_types_proto_goTypes = []any{
        (*ManagementConfig)(nil),        // 0: 
dubbo.operator.v1alpha1.ManagementConfig
        (*ProxyConfig)(nil),             // 1: 
dubbo.operator.v1alpha1.ProxyConfig
        (*ProxylessConfig)(nil),         // 2: 
dubbo.operator.v1alpha1.ProxylessConfig
        (*MeshCNIConfig)(nil),           // 3: 
dubbo.operator.v1alpha1.MeshCNIConfig
-       (*GlobalConfig)(nil),            // 4: 
dubbo.operator.v1alpha1.GlobalConfig
-       (*RemoteAccessConfig)(nil),      // 5: 
dubbo.operator.v1alpha1.RemoteAccessConfig
-       (*EastWestGatewayEndpoint)(nil), // 6: 
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
-       (*EastWestGatewayConfig)(nil),   // 7: 
dubbo.operator.v1alpha1.EastWestGatewayConfig
-       (*MulticlusterConfig)(nil),      // 8: 
dubbo.operator.v1alpha1.MulticlusterConfig
-       (*Values)(nil),                  // 9: dubbo.operator.v1alpha1.Values
-       (*IntOrString)(nil),             // 10: 
dubbo.operator.v1alpha1.IntOrString
-       (*wrapperspb.Int32Value)(nil),   // 11: google.protobuf.Int32Value
-       (*wrapperspb.StringValue)(nil),  // 12: google.protobuf.StringValue
+       (*GatewayConfig)(nil),           // 4: 
dubbo.operator.v1alpha1.GatewayConfig
+       (*GlobalConfig)(nil),            // 5: 
dubbo.operator.v1alpha1.GlobalConfig
+       (*RemoteAccessConfig)(nil),      // 6: 
dubbo.operator.v1alpha1.RemoteAccessConfig
+       (*EastWestGatewayEndpoint)(nil), // 7: 
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
+       (*EastWestGatewayConfig)(nil),   // 8: 
dubbo.operator.v1alpha1.EastWestGatewayConfig
+       (*MulticlusterConfig)(nil),      // 9: 
dubbo.operator.v1alpha1.MulticlusterConfig
+       (*Values)(nil),                  // 10: dubbo.operator.v1alpha1.Values
+       (*IntOrString)(nil),             // 11: 
dubbo.operator.v1alpha1.IntOrString
+       (*wrapperspb.Int32Value)(nil),   // 12: google.protobuf.Int32Value
+       (*wrapperspb.StringValue)(nil),  // 13: google.protobuf.StringValue
 }
 var file_values_types_proto_depIdxs = []int32{
        3,  // 0: dubbo.operator.v1alpha1.ProxylessConfig.cni:type_name -> 
dubbo.operator.v1alpha1.MeshCNIConfig
-       1,  // 1: dubbo.operator.v1alpha1.GlobalConfig.proxy:type_name -> 
dubbo.operator.v1alpha1.ProxyConfig
-       0,  // 2: dubbo.operator.v1alpha1.GlobalConfig.management:type_name -> 
dubbo.operator.v1alpha1.ManagementConfig
-       8,  // 3: dubbo.operator.v1alpha1.GlobalConfig.multicluster:type_name 
-> dubbo.operator.v1alpha1.MulticlusterConfig
-       2,  // 4: dubbo.operator.v1alpha1.GlobalConfig.proxyless:type_name -> 
dubbo.operator.v1alpha1.ProxylessConfig
-       6,  // 5: 
dubbo.operator.v1alpha1.EastWestGatewayConfig.gateways:type_name -> 
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
-       5,  // 6: 
dubbo.operator.v1alpha1.MulticlusterConfig.remoteAccess:type_name -> 
dubbo.operator.v1alpha1.RemoteAccessConfig
-       7,  // 7: 
dubbo.operator.v1alpha1.MulticlusterConfig.eastWestGateway:type_name -> 
dubbo.operator.v1alpha1.EastWestGatewayConfig
-       4,  // 8: dubbo.operator.v1alpha1.Values.global:type_name -> 
dubbo.operator.v1alpha1.GlobalConfig
-       11, // 9: dubbo.operator.v1alpha1.IntOrString.intVal:type_name -> 
google.protobuf.Int32Value
-       12, // 10: dubbo.operator.v1alpha1.IntOrString.strVal:type_name -> 
google.protobuf.StringValue
-       11, // [11:11] is the sub-list for method output_type
-       11, // [11:11] is the sub-list for method input_type
-       11, // [11:11] is the sub-list for extension type_name
-       11, // [11:11] is the sub-list for extension extendee
-       0,  // [0:11] is the sub-list for field type_name
+       12, // 1: dubbo.operator.v1alpha1.GatewayConfig.replicaCount:type_name 
-> google.protobuf.Int32Value
+       1,  // 2: dubbo.operator.v1alpha1.GlobalConfig.proxy:type_name -> 
dubbo.operator.v1alpha1.ProxyConfig
+       0,  // 3: dubbo.operator.v1alpha1.GlobalConfig.management:type_name -> 
dubbo.operator.v1alpha1.ManagementConfig
+       9,  // 4: dubbo.operator.v1alpha1.GlobalConfig.multicluster:type_name 
-> dubbo.operator.v1alpha1.MulticlusterConfig
+       2,  // 5: dubbo.operator.v1alpha1.GlobalConfig.proxyless:type_name -> 
dubbo.operator.v1alpha1.ProxylessConfig
+       4,  // 6: dubbo.operator.v1alpha1.GlobalConfig.gateway:type_name -> 
dubbo.operator.v1alpha1.GatewayConfig
+       7,  // 7: 
dubbo.operator.v1alpha1.EastWestGatewayConfig.gateways:type_name -> 
dubbo.operator.v1alpha1.EastWestGatewayEndpoint
+       6,  // 8: 
dubbo.operator.v1alpha1.MulticlusterConfig.remoteAccess:type_name -> 
dubbo.operator.v1alpha1.RemoteAccessConfig
+       8,  // 9: 
dubbo.operator.v1alpha1.MulticlusterConfig.eastWestGateway:type_name -> 
dubbo.operator.v1alpha1.EastWestGatewayConfig
+       5,  // 10: dubbo.operator.v1alpha1.Values.global:type_name -> 
dubbo.operator.v1alpha1.GlobalConfig
+       12, // 11: dubbo.operator.v1alpha1.Values.replicaCount:type_name -> 
google.protobuf.Int32Value
+       12, // 12: dubbo.operator.v1alpha1.IntOrString.intVal:type_name -> 
google.protobuf.Int32Value
+       13, // 13: dubbo.operator.v1alpha1.IntOrString.strVal:type_name -> 
google.protobuf.StringValue
+       14, // [14:14] is the sub-list for method output_type
+       14, // [14:14] is the sub-list for method input_type
+       14, // [14:14] is the sub-list for extension type_name
+       14, // [14:14] is the sub-list for extension extendee
+       0,  // [0:14] is the sub-list for field type_name
 }
 
 func init() { file_values_types_proto_init() }
@@ -916,7 +989,7 @@ func file_values_types_proto_init() {
                        GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
                        RawDescriptor: 
unsafe.Slice(unsafe.StringData(file_values_types_proto_rawDesc), 
len(file_values_types_proto_rawDesc)),
                        NumEnums:      0,
-                       NumMessages:   11,
+                       NumMessages:   12,
                        NumExtensions: 0,
                        NumServices:   0,
                },
diff --git a/operator/pkg/render/manifest_test.go 
b/operator/pkg/render/manifest_test.go
index 445c1be5..3a42bf5a 100644
--- a/operator/pkg/render/manifest_test.go
+++ b/operator/pkg/render/manifest_test.go
@@ -33,6 +33,108 @@ func TestGenerateManifestUsesEmbeddedInstallerAssets(t 
*testing.T) {
        }
 }
 
+// TestGenerateManifestDefaultsToHighlyAvailableControlPlane pins the default
+// posture: a single dubbod makes every node drain or upgrade a control-plane
+// outage, during which no pod can be injected and no xDS update is served.
+func TestGenerateManifestDefaultsToHighlyAvailableControlPlane(t *testing.T) {
+       manifests, _, err := GenerateManifest(nil, nil, nil, nil)
+       if err != nil {
+               t.Fatalf("GenerateManifest() error = %v", err)
+       }
+
+       deployment := findManifest(t, manifests, "Deployment", "dubbod")
+       replicas, ok, err := unstructured.NestedInt64(deployment.Object, 
"spec", "replicas")
+       if err != nil || !ok {
+               t.Fatalf("replicas missing: ok=%v err=%v", ok, err)
+       }
+       if replicas < 2 {
+               t.Fatalf("dubbod replicas = %d, want at least 2", replicas)
+       }
+
+       // The budget only protects the control plane if it is actually 
rendered at
+       // the default replica count.
+       budget := findManifest(t, manifests, "PodDisruptionBudget", "dubbod")
+       minAvailable, ok, err := unstructured.NestedInt64(budget.Object, 
"spec", "minAvailable")
+       if err != nil || !ok {
+               t.Fatalf("minAvailable missing: ok=%v err=%v", ok, err)
+       }
+       if minAvailable != 1 {
+               t.Fatalf("dubbod PodDisruptionBudget minAvailable = %d, want 
1", minAvailable)
+       }
+
+       constraints, ok, err := unstructured.NestedSlice(deployment.Object, 
"spec", "template", "spec", "topologySpreadConstraints")
+       if err != nil || !ok || len(constraints) == 0 {
+               t.Fatalf("topologySpreadConstraints missing: ok=%v err=%v", ok, 
err)
+       }
+       for _, raw := range constraints {
+               constraint := raw.(map[string]interface{})
+               // Spreading must stay advisory, or a single-node cluster 
leaves the
+               // second replica permanently Pending.
+               if policy, _, _ := unstructured.NestedString(constraint, 
"whenUnsatisfiable"); policy != "ScheduleAnyway" {
+                       t.Fatalf("whenUnsatisfiable = %q, want ScheduleAnyway", 
policy)
+               }
+       }
+}
+
+// TestGenerateManifestSingleReplicaOmitsDisruptionBudget guards the other
+// direction: a budget of minAvailable 1 over a single replica blocks every
+// voluntary eviction, so node drains hang instead of proceeding.
+func TestGenerateManifestSingleReplicaOmitsDisruptionBudget(t *testing.T) {
+       manifests, _, err := GenerateManifest(nil, 
[]string{"values.replicaCount=1"}, nil, nil)
+       if err != nil {
+               t.Fatalf("GenerateManifest() error = %v", err)
+       }
+       for _, set := range manifests {
+               for _, item := range set.Manifests {
+                       if item.GetKind() == "PodDisruptionBudget" && 
item.GetName() == "dubbod" {
+                               t.Fatal("PodDisruptionBudget rendered for a 
single-replica control plane")
+                       }
+               }
+       }
+}
+
+// TestGenerateManifestPassesGatewayReplicaDefault walks the whole chain the
+// gateway replica default travels: chart values, the operator API schema, and
+// the environment variable dubbod reads at runtime. A break anywhere in it is
+// silent, because the controller simply falls back to its own constant.
+func TestGenerateManifestPassesGatewayReplicaDefault(t *testing.T) {
+       for _, test := range []struct {
+               name string
+               set  []string
+               want string
+       }{
+               {name: "default", want: "2"},
+               {name: "override", set: 
[]string{"values.global.gateway.replicaCount=3"}, want: "3"},
+       } {
+               t.Run(test.name, func(t *testing.T) {
+                       manifests, _, err := GenerateManifest(nil, test.set, 
nil, nil)
+                       if err != nil {
+                               t.Fatalf("GenerateManifest() error = %v", err)
+                       }
+                       deployment := findManifest(t, manifests, "Deployment", 
"dubbod")
+                       containers, ok, err := 
unstructured.NestedSlice(deployment.Object, "spec", "template", "spec", 
"containers")
+                       if err != nil || !ok || len(containers) == 0 {
+                               t.Fatalf("deployment containers missing: ok=%v 
err=%v", ok, err)
+                       }
+                       env, ok, err := 
unstructured.NestedSlice(containers[0].(map[string]interface{}), "env")
+                       if err != nil || !ok {
+                               t.Fatalf("container env missing: ok=%v err=%v", 
ok, err)
+                       }
+                       for _, raw := range env {
+                               entry := raw.(map[string]interface{})
+                               if name, _, _ := 
unstructured.NestedString(entry, "name"); name != "DUBBO_DXGATE_REPLICAS" {
+                                       continue
+                               }
+                               if value, _, _ := 
unstructured.NestedString(entry, "value"); value != test.want {
+                                       t.Fatalf("DUBBO_DXGATE_REPLICAS = %q, 
want %q", value, test.want)
+                               }
+                               return
+                       }
+                       t.Fatal("DUBBO_DXGATE_REPLICAS not rendered")
+               })
+       }
+}
+
 func TestTelemetryValidationWebhookDoesNotRequireRevisionLabel(t *testing.T) {
        manifests, _, err := GenerateManifest(nil, nil, nil, nil)
        if err != nil {
diff --git a/pkg/kube/inject/proxyless.go b/pkg/kube/inject/proxyless.go
index 4b9a6e7f..2468d3c6 100644
--- a/pkg/kube/inject/proxyless.go
+++ b/pkg/kube/inject/proxyless.go
@@ -47,8 +47,12 @@ const (
        ProxylessGRPCConfigPath                      = ProxylessXDSMountPath + 
"/" + ProxylessGRPCConfigFileName
        ProxylessGRPCInboundContainerName            = "dubbo-grpc-inbound"
        ProxylessGRPCInboundPort                     = 15080
-       ProxylessManagedLabel                        = 
"proxyless.dubbo.apache.org/managed"
-       ProxylessManagedLabelValue                   = "true"
+       // ProxylessGRPCInboundAdminPort serves the inbound sidecar's health,
+       // readiness and metrics endpoints. It carries no mesh traffic, so the 
node
+       // fence exempts it and kubelet can probe it directly.
+       ProxylessGRPCInboundAdminPort = 15020
+       ProxylessManagedLabel         = "proxyless.dubbo.apache.org/managed"
+       ProxylessManagedLabelValue    = "true"
 )
 
 // ProxylessExcludeInboundPortsAnnotation lists inbound ports that stay
diff --git a/pkg/kube/inject/proxyless_test.go 
b/pkg/kube/inject/proxyless_test.go
index a1c0b91e..82917e84 100644
--- a/pkg/kube/inject/proxyless_test.go
+++ b/pkg/kube/inject/proxyless_test.go
@@ -21,6 +21,7 @@ import (
        "runtime"
        "strings"
        "testing"
+       "time"
 
        telemetryconfig 
"github.com/apache/dubbo-kubernetes/pkg/config/telemetry"
        meshv1alpha1 "github.com/kdubbo/api/mesh/v1alpha1"
@@ -243,6 +244,10 @@ func assertNoArgs(t *testing.T, pod *corev1.Pod) {
        }
 }
 
+// proxylessDrainDelay mirrors the sidecar's default termination drain delay.
+// The readiness probe must detect termination inside this window.
+const proxylessDrainDelay = 5 * time.Second
+
 func assertGRPCInboundContainer(t *testing.T, pod *corev1.Pod) {
        t.Helper()
        container := FindContainer(ProxylessGRPCInboundContainerName, 
pod.Spec.Containers)
@@ -259,6 +264,40 @@ func assertGRPCInboundContainer(t *testing.T, pod 
*corev1.Pod) {
        if !hasMount(container.VolumeMounts, ProxylessXDSVolumeName, 
ProxylessXDSMountPath, true) {
                t.Fatalf("grpc-inbound proxyless xds mount missing")
        }
+       assertDrainReadinessProbe(t, container)
+}
+
+// assertDrainReadinessProbe checks the probe that withdraws a terminating pod
+// from its EndpointSlice. Without it the sidecar's drain delay is inert: 
kubelet
+// never observes the 503, so the endpoint is still published when the listener
+// closes.
+func assertDrainReadinessProbe(t *testing.T, container *corev1.Container) {
+       t.Helper()
+       probe := container.ReadinessProbe
+       if probe == nil || probe.HTTPGet == nil {
+               t.Fatalf("grpc-inbound readiness probe missing")
+       }
+       if probe.HTTPGet.Path != "/readyz" || probe.HTTPGet.Port.IntValue() != 
ProxylessGRPCInboundAdminPort {
+               t.Fatalf("grpc-inbound readiness probe = %s:%v, want 
/readyz:%d",
+                       probe.HTTPGet.Path, probe.HTTPGet.Port, 
ProxylessGRPCInboundAdminPort)
+       }
+       // The probe has to fail before the sidecar stops accepting, otherwise 
the
+       // endpoint is withdrawn only after the listener is already gone.
+       if detection := 
time.Duration(probe.PeriodSeconds*probe.FailureThreshold) * time.Second; 
detection >= proxylessDrainDelay {
+               t.Fatalf("readiness detection window = %v, want less than the 
%v drain delay", detection, proxylessDrainDelay)
+       }
+       if !hasContainerPort(container.Ports, ProxylessGRPCInboundAdminPort) {
+               t.Fatalf("grpc-inbound admin port %d not declared", 
ProxylessGRPCInboundAdminPort)
+       }
+}
+
+func hasContainerPort(ports []corev1.ContainerPort, want int) bool {
+       for _, port := range ports {
+               if int(port.ContainerPort) == want {
+                       return true
+               }
+       }
+       return false
 }
 
 func TestAddApplicationContainerConfigInjectsProxylessGRPCContract(t 
*testing.T) {
diff --git a/samples/autoscaling/README.md b/samples/autoscaling/README.md
new file mode 100644
index 00000000..99c6d672
--- /dev/null
+++ b/samples/autoscaling/README.md
@@ -0,0 +1,77 @@
+## autoscaling example
+
+用 KEDA 把队列消费者从 0 弹到 N,同时保持网格的 mTLS 和 xDS 不变。
+
+KEDA 不是本项目的依赖,chart 不安装也不托管它。这里的 `ScaledObject` 是原生 KEDA 资源,
+脱离网格照样有效。
+
+### 前置
+
+```bash
+helm repo add kedacore https://kedacore.github.io/charts
+helm install keda kedacore/keda -n keda --create-namespace
+```
+
+### 部署
+
+```bash
+kubectl create ns autoscaling
+kubectl label ns autoscaling dubbo-injection=enabled
+kubectl apply -f samples/autoscaling/kafka-consumer.yaml
+kubectl apply -f samples/autoscaling/scaledobject.yaml
+
+kubectl -n autoscaling get scaledobject order-consumer
+kubectl -n autoscaling get deploy order-consumer -w
+```
+
+topic 空闲时副本数降到 0,有积压时自动拉起。
+
+### 哪些工作负载可以缩到零
+
+| 类型 | 策略 | 原因 |
+| --- | --- | --- |
+| 队列消费者、定时任务 | 0↔N | 没有入向调用方,副本消失不会让谁的请求落空 |
+| 启动快的无状态 Unary 服务 | 1↔N | 东西向同步激活还没有,缩到 0 后调用方直接失败 |
+| 启动慢的 Java 服务 | 1↔N + 预热 | 冷启动几分钟,弹性来不及 |
+| Streaming、长连接 | 不缩到 0 | 缩容会切断进行中的流 |
+| StatefulSet、强状态服务 | 不缩到 0 | 副本身份和存储不是可丢弃的 |
+
+### 为什么 HTTP/gRPC 服务现在不能缩到零
+
+出向是 proxyless 的:调用方进程内的 gRPC xDS client 直接拿 EDS 端点建连。副本归零时
+dubbod 下发一个空的 CLA(`dubbod/discovery/pkg/xds/endpoints/endpoint_builder.go`),
+调用方立即失败。请求路径上没有任何组件能扣住这个请求去触发扩容 —— sidecar 网格里由
+sidecar 或 activator 承担的那个位置,在这里是空的。
+
+所以对被调用的服务用 `minReplicaCount: 1`。等 dxgate 的 Activator 模式落地后,
+北南向的 Unary 请求才能按需激活。
+
+### 副本数只能有一个归属
+
+`ScaledObject` 一旦生效,`Deployment.spec.replicas` 就交给 KEDA 了。两边都写会让
+KEDA 和 Deployment 控制器来回改同一个数字。`kafka-consumer.yaml` 里因此没有 `replicas` 字段。
+
+同理,不要给受 KEDA 管的 Deployment 再挂一个 HPA。
+
+### 缩容时的排空
+
+被缩掉的 pod 会走注入的 dxplane 排空流程:先 5s 只失败 readiness(让 EndpointSlice 摘掉),
+再关监听并等在途连接最多 25s。`terminationGracePeriodSeconds` 必须大于两者之和,
+样例里设的是 40s。设成默认 30s 也够,但没有余量。
+
+排空是否超预算,看 `dxplane_connections_force_closed_total`:非零说明 25s 不够,
+调 `DUBBO_GRPC_INBOUND_TERMINATION_DRAIN_DURATION`。
+
+### 冷启动的代价
+
+每个新副本都要重新取证书、建 xDS 连接、等首次 CDS/EDS 收敛,这笔开销在 sidecar 方案里
+由常驻 sidecar 摊销掉,proxyless 下每个副本重付。上生产前先量一下从 Pod Running 到
+能收发流量的时间,再决定 `cooldownPeriod` 和 `stabilizationWindowSeconds`。
+
+### 检查高可用配置
+
+```bash
+dubboctl analyze -n autoscaling
+```
+
+会报出单副本的控制面和网关、缺 PodDisruptionBudget、以及副本全落在同一个节点上的情况。
diff --git a/samples/autoscaling/kafka-consumer.yaml 
b/samples/autoscaling/kafka-consumer.yaml
new file mode 100644
index 00000000..d24c51d5
--- /dev/null
+++ b/samples/autoscaling/kafka-consumer.yaml
@@ -0,0 +1,59 @@
+# 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.
+
+# A queue consumer: it pulls work from Kafka and calls other mesh services, but
+# nothing calls it. That is what makes it safe to scale to zero — no caller can
+# be left holding a request against an endpoint that no longer exists.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: order-consumer
+  namespace: autoscaling
+  labels:
+    app: order-consumer
+spec:
+  # No replicas field: KEDA owns the replica count through the ScaledObject.
+  # Setting it here makes two controllers fight over the same number.
+  selector:
+    matchLabels:
+      app: order-consumer
+  template:
+    metadata:
+      labels:
+        app: order-consumer
+    spec:
+      # The inbound sidecar drains in two phases (5s endpoint withdrawal, then
+      # up to 25s for in-flight connections). Anything below 30s here lets
+      # kubelet send SIGKILL mid-drain.
+      terminationGracePeriodSeconds: 40
+      containers:
+        - name: consumer
+          # Replace with your own consumer image.
+          image: kdubbo/order-consumer:latest
+          imagePullPolicy: IfNotPresent
+          env:
+            - name: KAFKA_BOOTSTRAP_SERVERS
+              value: kafka.kafka.svc.cluster.local:9092
+            - name: KAFKA_TOPIC
+              value: orders
+            - name: KAFKA_CONSUMER_GROUP
+              value: order-consumer
+          resources:
+            requests:
+              cpu: 100m
+              memory: 128Mi
+            limits:
+              cpu: 500m
+              memory: 512Mi
diff --git a/samples/autoscaling/scaledobject.yaml 
b/samples/autoscaling/scaledobject.yaml
new file mode 100644
index 00000000..3009413d
--- /dev/null
+++ b/samples/autoscaling/scaledobject.yaml
@@ -0,0 +1,51 @@
+# 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.
+
+# KEDA owns the replica count. The mesh does not install, manage or wrap KEDA;
+# these are stock KEDA resources and stay valid without the mesh.
+apiVersion: keda.sh/v1alpha1
+kind: ScaledObject
+metadata:
+  name: order-consumer
+  namespace: autoscaling
+spec:
+  scaleTargetRef:
+    name: order-consumer
+  # Zero is safe here because the consumer has no inbound callers. An
+  # HTTP or gRPC service reached over the mesh must use minReplicaCount: 1
+  # until synchronous activation exists.
+  minReplicaCount: 0
+  maxReplicaCount: 20
+  # Long enough that a burst of short gaps in the topic does not scale the
+  # consumer down into a cold start on the next message.
+  cooldownPeriod: 300
+  pollingInterval: 15
+  advanced:
+    horizontalPodAutoscalerConfig:
+      behavior:
+        scaleDown:
+          # Cold start costs a certificate issue plus an xDS convergence per
+          # replica, so scale down slowly and scale up freely.
+          stabilizationWindowSeconds: 300
+  triggers:
+    - type: kafka
+      metadata:
+        bootstrapServers: kafka.kafka.svc.cluster.local:9092
+        consumerGroup: order-consumer
+        topic: orders
+        lagThreshold: "100"
+        # Required for 0 -> 1: without it KEDA cannot distinguish an idle
+        # topic from an unreachable one and refuses to activate.
+        activationLagThreshold: "1"

Reply via email to