Tonic client load balancing for Kubernetes.
This crate provides client-side load balancing for Tonic-based gRPC applications running in Kubernetes. It does that by watching the target service's EndpointSlices and feeding changes to the client channel, thus enabling responsive load balancing across pod replicas.
Standard Kubernetes ClusterIP services don't load balance gRPC effectively. HTTP/2 multiplexes all requests over a single long-lived TCP connection, so all traffic goes to one pod. Headless services expose individual pod IPs, but the client must:
- Discover all pod endpoints
- Maintain connections to each
- Load balance requests across them
- React to pods being added/removed
This crate handles all of that automatically.
Add tonic and tonic-lb-k8s to your Cargo.toml:
[dependencies]
tonic = "0.14"
tonic-lb-k8s = "0.1"This crate uses rustls for TLS. Choose a root certificate feature based on your deployment:
# For containers with system CA certificates (Alpine, Debian, etc.)
# Enables native/system roots for tonic
tonic-lb-k8s = { version = "0.1", features = ["tls-native-roots"] }
# For scratch/distroless images (no system CA certs)
# Embeds Mozilla's root certs for both kube and tonic
tonic-lb-k8s = { version = "0.1", features = ["tls-webpki-roots"] }| Feature | kube | tonic | Use case |
|---|---|---|---|
| (none) | System certs (default) | No roots configured | kube-only TLS |
tls-native-roots |
System certs (default) | System certs | Containers with CA certs |
tls-webpki-roots |
Embedded Mozilla certs | Embedded Mozilla certs | scratch/distroless |
use std::net::SocketAddr;
use std::time::Duration;
use tonic::transport::{Channel, Endpoint};
use tonic_lb_k8s::{discover, DiscoveryConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create your own balance channel
let (channel, tx) = Channel::balance_channel::<SocketAddr>(1024);
// Start discovery - build function returns Endpoint for each address
let config = DiscoveryConfig::new("my-grpc-service", 50051);
discover(config, tx, |addr| {
Endpoint::from_shared(format!("http://{addr}"))
.unwrap()
.connect_timeout(Duration::from_secs(5))
});
// Use with your generated gRPC client
let client = MyServiceClient::new(channel);
let response = client.some_method(request).await?;
Ok(())
}use std::net::SocketAddr;
use std::time::Duration;
use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
use tonic_lb_k8s::{discover, DiscoveryConfig};
let (channel, tx) = Channel::balance_channel::<SocketAddr>(1024);
let config = DiscoveryConfig::new("my-grpc-service", 50051);
let tls = ClientTlsConfig::new();
discover(config, tx, move |addr| {
Endpoint::from_shared(format!("https://{addr}"))
.unwrap()
.tls_config(tls.clone())
.unwrap()
.connect_timeout(Duration::from_secs(5))
});Applications using this crate require Kubernetes RBAC permissions to watch EndpointSlice resources.
| API Group | Resource | Verbs |
|---|---|---|
discovery.k8s.io |
endpointslices |
list, watch |
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: endpointslice-reader
namespace: <your-namespace>
rules:
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: <your-app>-endpointslice-reader
namespace: <your-namespace>
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: endpointslice-reader
subjects:
- kind: ServiceAccount
name: <your-service-account>
namespace: <your-namespace>For cross-namespace discovery, use a ClusterRole and ClusterRoleBinding instead.
See the examples directory for a complete demonstration including:
- A sample gRPC server and client
- Dockerfiles for Alpine/musl builds
- Kubernetes manifests
- Deployment script
Licensed under the MIT license.