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 <[email protected]>
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"