Building a gRPC custom scaler with the KEDA SDK, understanding how KEDA talks to HPA through the External Metrics API, and composing precise multi-trigger AND/OR combinations in a single ScaledObject with scalingModifiers.

In episode 9 we got to know scalers for databases and HTTP. Even though KEDA has more than 70 scalers, the real world always has metrics that aren't covered — for example, internal system backlogs, your own SaaS metrics, or legacy mainframes. In this episode you'll learn two things: how KEDA talks to HPA through the External Metrics API, and how to build a gRPC custom scaler to connect internal systems to KEDA. Finally, we'll compose multi-triggers with precise AND/OR logic.
KEDA doesn't scale pods directly. The flow is layered: the scaler reads metrics from an external system, then the KEDA Metrics Server exposes the value through the External Metrics API, and the HPA that KEDA creates queries that API every synchronization cycle (usually 15 seconds) to determine the replica count.
kubectl get apiservice v1beta1.external.metrics.k8s.io
kubectl get --raw /apis/external.metrics.k8s.io/v1beta1
kubectl get hpa -n productionThis path is what makes KEDA compatible with standard Kubernetes HPA: KEDA simply acts as a metric source and manages the HPA's lifecycle. For the same reason, KEDA can't share a custom API server with other solutions in the same cluster.
If the 70+ built-in scalers still don't meet your needs, KEDA provides a gRPC-based external scaler interface. You write a gRPC server that implements the externalscaler protocol with three core methods:
IsActive — determines whether the system has events (used for scale-to-zero decisions).GetMetricsSpec — tells KEDA the metric name and its target size.GetMetricsAndReset — returns the latest metric values for HPA to compute.package main
import (
"context"
pb "github.com/kedacore/keda/v2/pkg/scalers/externalscaler"
)
type Scaler struct {
pb.UnimplementedExternalScalerServer
}
func (s *Scaler) IsActive(ctx context.Context, req *pb.ScaledObjectRef) (*pb.IsActiveResponse, error) {
n := countInternalBacklog()
return &pb.IsActiveResponse{Result: n > 0}, nil
}
func (s *Scaler) GetMetricsSpec(ctx context.Context, req *pb.ScaledObjectRef) (*pb.GetMetricsSpecResponse, error) {
return &pb.GetMetricsSpecResponse{
MetricSpecs: []*pb.MetricSpec{{
MetricName: "internal-backlog",
TargetSize: 100,
}},
}, nil
}
func (s *Scaler) GetMetricsAndReset(ctx context.Context, req *pb.GetMetricsRequest) (*pb.GetMetricsResponse, error) {
return &pb.GetMetricsResponse{
MetricValues: []*pb.MetricValue{{
MetricName: "internal-backlog",
MetricValue: countInternalBacklog(),
}},
}, nil
}This gRPC server runs as a separate Deployment inside the cluster, then gets connected to KEDA through the gRPCConfig block. Note countInternalBacklog — replace it with real logic that reads your internal system.
KEDA reaches the gRPC server via the gRPCConfig configuration, and forwards the metadata written in the ScaledObject as trigger parameters.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: internal-scaledobject
spec:
scaleTargetRef:
name: internal-worker
minReplicaCount: 0
maxReplicaCount: 10
triggers:
- type: external
metadata:
scalerAddress: internal-scaler.prod.svc:6000
metricName: internal-backlog
targetValue: "100"
gRPCConfig:
host: internal-scaler.prod.svc
port: "6000"
useCachedClients: "true"Tip
Enable useCachedClients: true so the operator doesn't create a new gRPC connection for each poll — the connection is opened once and then reused. If your internal scaler needs TLS, add the certificate in the gRPCConfig block.
KEDA combines several triggers in one ScaledObject with two important rules.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: hybrid
spec:
scaleTargetRef:
name: hybrid-api
minReplicaCount: 1
maxReplicaCount: 20
triggers:
- type: cpu
metricType: Utilization
metadata:
type: Utilization
value: "60"
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
query: sum(rate(http_requests_total[2m]))
threshold: "100"Because summation doesn't always fit your needs, KEDA v2.20 provides scalingModifiers with expression formulas — including the ternary operator for precise AND/OR logic.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: or-logic
spec:
scaleTargetRef:
name: worker
minReplicaCount: 0
maxReplicaCount: 10
advanced:
scalingModifiers:
formula: "max(trig_a, trig_b)"
target: "10"
metricType: "AverageValue"
triggers:
- type: prometheus
name: trig_a
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
query: sum(rate(errors_total[1m]))
threshold: "5"
- type: metrics-api
name: trig_b
metadata:
url: "http://internal-api.prod.svc:8080/backlog"
valueLocation: "backlog"
targetValue: "10"The max(trig_a, trig_b) formula makes KEDA use the highest value of the two triggers — pure OR logic. For AND, use min(...). Other combinations such as trig_a + trig_b or the ternary trig_a > 2 ? trig_a + trig_b : 1 can also be written directly.
Warning
When combining triggers, pay attention to the metric scale. Combining a trigger in the thousands with a trigger in the single digits through summation will make the small trigger invisible. Normalize the values or use max/min via scalingModifiers.
kubectl get --raw /apis/external.metrics.k8s.io/v1beta1.IsActive, GetMetricsSpec, and GetMetricsAndReset.gRPCConfig and metadata in the ScaledObject.scalingModifiers with formulas gives precise AND/OR control for complex needs.Your scalers can now be anything — but what happens when a scaler fails to read metrics? In episode 11 we discuss fallback and advanced config: keeping minimum availability when a scaler errors, and setting pollingInterval, cooldownPeriod, restoreToOriginalReplicaCount, HPA behavior, and scalingStrategy. See you there!