Lesson 21 of 30
Production gRPC: TLS, Health, Load Balancing, and Testing
This lesson adds the production pieces to the Catalog gRPC service from gRPC basics and gRPC streaming: health checks, reflection, TLS, client-side balancing, gRPC-Web, one port shared with REST, and tests. The last example is the complete service.
Project setup
The crate is a library (schema and service) plus several binaries. protoc must be installed for tonic-build. Tonic 0.13 depends on Axum 0.8, which keeps the router types aligned with the rest of the course.
Cargo.toml
[package]
name = "catalog-service"
version = "0.1.0"
edition = "2021"
default-run = "catalog-service"
[dependencies]
axum = "0.8"
prost = "0.13"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
tonic = { version = "0.13", features = ["tls-ring"] }
tonic-health = "0.13"
tonic-reflection = "0.13"
tonic-web = "0.13"
tower-http = { version = "0.6", features = ["cors"] }
[build-dependencies]
tonic-build = "0.13"
[dev-dependencies]
tokio-stream = { version = "0.1", features = ["net"] }The files proto/catalog.proto, build.rs, and src/lib.rs (the shared service) are listed in full under Complete example. build.rs also writes a file descriptor set (the compiled schema), which reflection serves in Example 4.
Example 1: Plain server with the health protocol
The gRPC health checking protocol is a standard service, grpc.health.v1.Health, with Check (one answer) and Watch (a stream of changes). The tonic-health crate implements it. Kubernetes can probe it natively with a grpc probe, so the Deployment needs no HTTP endpoint (Docker, Kubernetes and CI).
src/bin/plain_server.rs
use std::net::SocketAddr;
use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::Server;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = std::env::var("LISTEN")
.unwrap_or_else(|_| "127.0.0.1:50051".into())
.parse()?;
let (reporter, health_service) = tonic_health::server::health_reporter();
reporter
.set_serving::<CatalogServer<CatalogService>>()
.await;
Server::builder()
.add_service(health_service)
.add_service(CatalogServer::new(CatalogService::seeded()))
.serve(addr)
.await?;
Ok(())
}health_reporter()returns a handle to change the reported state and the service to register.set_serving::<S>()marks the service named by the generated server type asSERVING.set_not_serving::<S>()marks itNOT_SERVING.- Set
NOT_SERVINGwhen the database pool is down or during shutdown, so the orchestrator stops routing calls while in-flight calls finish (health and shutdown).
Run it with cargo run --bin plain_server.
Example 2: TLS and mutual TLS
TLS encrypts the connection and lets the client verify the server's certificate. In mutual TLS (mTLS) the client also presents a certificate, and the server verifies it against a certificate authority (CA, the party that signs certificates). Connections without a valid client certificate fail in the handshake, before any handler runs.
Create a CA, a server certificate for localhost, and a client certificate. The extension files make the certificates X.509 version 3, which rustls requires:
mkdir certs && cd certs
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout ca.key -out ca.pem -subj "/CN=storefront-ca"
printf "subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth" > san.ext
printf "extendedKeyUsage=clientAuth" > client.ext
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=localhost"
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 30 -out server.pem -extfile san.ext
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=orders"
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 30 -out client.pem -extfile client.ext
cd ..src/bin/tls_server.rs
use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cert = std::fs::read_to_string("certs/server.pem")?;
let key = std::fs::read_to_string("certs/server.key")?;
let client_ca = std::fs::read_to_string("certs/ca.pem")?;
let tls = ServerTlsConfig::new()
.identity(Identity::from_pem(cert, key))
.client_ca_root(Certificate::from_pem(client_ca));
Server::builder()
.tls_config(tls)?
.add_service(CatalogServer::new(CatalogService::seeded()))
.serve("127.0.0.1:50443".parse()?)
.await?;
Ok(())
}src/bin/tls_client.rs
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let ca = std::fs::read_to_string("certs/ca.pem")?;
let cert = std::fs::read_to_string("certs/client.pem")?;
let key = std::fs::read_to_string("certs/client.key")?;
let tls = ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(ca))
.domain_name("localhost")
.identity(Identity::from_pem(cert, key));
let channel = Channel::from_static("https://localhost:50443")
.tls_config(tls)?
.connect()
.await?;
let mut client = CatalogClient::new(channel);
let product = client
.get_product(GetProductRequest { id: 1 })
.await?
.into_inner();
println!("{} {}", product.name, product.price_cents);
Ok(())
}Run cargo run --bin tls_server in one terminal and cargo run --bin tls_client in another. The client prints:
Notebook 450- Removing
client_ca_rooton the server andidentityon the client gives plain TLS. domain_namemust match a name in the server certificate (localhostthrough thesubjectAltNameabove). A mismatch fails the handshake.- A valid client certificate proves who connected. Which caller may call which method is still an authorization check in code, or a policy in a service mesh (OAuth, JWT and mTLS).
- Keys and certificates are secrets. Mount them from a secret store, never from the repository.
Example 3: Client-side load balancing
HTTP/2 multiplexes many calls over one long-lived connection. A layer 4 balancer (one that balances TCP connections) picks a backend when the connection opens, so every call on that connection reaches the same instance. Two fixes exist: a layer 7 proxy such as Envoy that balances per call, or a client that holds a connection to each instance and picks one per call. Discovery options are in gateway and service discovery.
Start three instances of Example 1 on different ports:
LISTEN=127.0.0.1:50051 cargo run --bin plain_server &
LISTEN=127.0.0.1:50052 cargo run --bin plain_server &
LISTEN=127.0.0.1:50053 cargo run --bin plain_server &src/bin/balanced_client.rs
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Channel, Endpoint};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let endpoints = [
"http://127.0.0.1:50051",
"http://127.0.0.1:50052",
"http://127.0.0.1:50053",
]
.into_iter()
.map(Endpoint::from_static);
let channel = Channel::balance_list(endpoints);
let mut client = CatalogClient::new(channel);
for _ in 0..6 {
client.get_product(GetProductRequest { id: 1 }).await?;
}
println!("6 calls succeeded");
Ok(())
}6 calls succeededChannel::balance_list spreads calls over the listed endpoints, and which instance serves which call is not fixed. The list is static. A changing set uses Channel::balance_channel, which accepts a stream of add and remove changes from a discovery component. In Kubernetes a plain ClusterIP Service is one virtual address and acts like a layer 4 balancer. A headless Service returns pod addresses through DNS, which a client can balance across.
Example 4: Reflection, gRPC-Web, and REST on one port
Three additions go into one Axum process:
- Reflection lets a client ask the server for its schema, so
grpcurlworks without.protofiles. It exposes the whole API surface, so enable it deliberately on public endpoints. - gRPC-Web is a protocol variant that carries trailers inside the body, because browsers cannot read HTTP/2 trailers.
tonic-webtranslates it. Only unary and server-streaming calls work from browsers. Browsers also send a CORS preflight, soCorsLayerfrom Tower middleware is added. - REST beside gRPC:
Routescollects Tonic services and converts them into an Axum router, which merges with REST routes. Requests withcontent-type: application/grpcreach the gRPC paths, and other paths reach the REST handlers.
The REST handler calls CatalogService::find, the same function the gRPC method uses, so the two protocols share one implementation of the rule.
src/main.rs
use std::net::SocketAddr;
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use catalog_service::{
pb::{catalog_server::CatalogServer, FILE_DESCRIPTOR_SET},
CatalogService,
};
use serde_json::{json, Value};
use tonic::service::Routes;
use tonic_web::GrpcWebLayer;
use tower_http::cors::{Any, CorsLayer};
async fn healthz() -> &'static str {
"ok"
}
async fn get_product(
State(catalog): State<CatalogService>,
Path(id): Path<i64>,
) -> Result<Json<Value>, StatusCode> {
let product = catalog.find(id).ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(json!({
"id": product.id,
"name": product.name,
"price_cents": product.price_cents,
})))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = std::env::var("LISTEN")
.unwrap_or_else(|_| "127.0.0.1:8080".into())
.parse()?;
let catalog = CatalogService::seeded();
let (reporter, health_service) = tonic_health::server::health_reporter();
reporter
.set_serving::<CatalogServer<CatalogService>>()
.await;
let reflection = tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET)
.build_v1()?;
let grpc = Routes::new(CatalogServer::new(catalog.clone()))
.add_service(health_service)
.add_service(reflection)
.into_axum_router()
.layer(GrpcWebLayer::new())
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
.expose_headers(Any),
);
let rest = Router::new()
.route("/healthz", get(healthz))
.route("/products/{id}", get(get_product))
.with_state(catalog);
let app = rest.merge(grpc);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}Routes::newtakes the first service, andadd_serviceadds more.- The gRPC-Web and CORS layers wrap only the gRPC router, so REST routes are unchanged.
- The Axum version behind
Routes::into_axum_routermust match the application's Axum version, or the router types do not match.
Example 5: Tests over a real listener
A handler is an async method, so logic can be tested by calling it directly. To cover encoding and the transport, bind a listener on port 0 (the operating system picks a free port) and connect a client to it.
tests/e2e.rs
use catalog_service::{
pb::{catalog_client::CatalogClient, catalog_server::CatalogServer, GetProductRequest},
CatalogService,
};
use tokio_stream::wrappers::TcpListenerStream;
use tonic::{transport::Server, Code};
async fn start_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
Server::builder()
.add_service(CatalogServer::new(CatalogService::seeded()))
.serve_with_incoming(TcpListenerStream::new(listener))
.await
.unwrap();
});
format!("http://{addr}")
}
#[tokio::test]
async fn returns_seeded_product() {
let url = start_server().await;
let mut client = CatalogClient::connect(url).await.unwrap();
let product = client
.get_product(GetProductRequest { id: 1 })
.await
.unwrap()
.into_inner();
assert_eq!(product.name, "Notebook");
}
#[tokio::test]
async fn unknown_product_is_not_found() {
let url = start_server().await;
let mut client = CatalogClient::connect(url).await.unwrap();
let error = client
.get_product(GetProductRequest { id: 99 })
.await
.unwrap_err();
assert_eq!(error.code(), Code::NotFound);
}serve_with_incoming takes a stream of connections, so the test owns the listener and never hard-codes a port. Keep .proto files in one place and run a breaking-change check (such as buf breaking) in CI against the last released schema (service testing).
REST or gRPC
| Criterion | Favors REST | Favors gRPC |
|---|---|---|
| Browsers and third-party clients | Native | Needs gRPC-Web, unary and server streaming only |
| Internal service to service | Works | Typed contract, binary payloads |
| Streaming | SSE or WebSocket | Native streams |
| HTTP caching | Works for GET | Not available |
| Debugging with basic tools | curl |
Needs reflection and grpcurl |
A system often uses both: REST or GraphQL at the edge, gRPC inside. Timeouts, retries, and failure handling from resilience patterns apply to both.
Complete example
The service from Example 4, its test, and the binaries from Examples 1 to 3. default-run makes cargo run start src/main.rs. The certs/ directory comes from the commands in Example 2.
catalog-service/
├── Cargo.toml
├── build.rs
├── proto/
│ └── catalog.proto
├── certs/ (created in Example 2)
├── src/
│ ├── lib.rs
│ ├── main.rs
│ └── bin/
│ ├── plain_server.rs
│ ├── tls_server.rs
│ ├── tls_client.rs
│ └── balanced_client.rs
└── tests/
└── e2e.rsCargo.toml
[package]
name = "catalog-service"
version = "0.1.0"
edition = "2021"
default-run = "catalog-service"
[dependencies]
axum = "0.8"
prost = "0.13"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
tonic = { version = "0.13", features = ["tls-ring"] }
tonic-health = "0.13"
tonic-reflection = "0.13"
tonic-web = "0.13"
tower-http = { version = "0.6", features = ["cors"] }
[build-dependencies]
tonic-build = "0.13"
[dev-dependencies]
tokio-stream = { version = "0.1", features = ["net"] }proto/catalog.proto
syntax = "proto3";
package storefront.catalog.v1;
message GetProductRequest {
int64 id = 1;
}
message Product {
int64 id = 1;
string name = 2;
int64 price_cents = 3;
ProductStatus status = 4;
repeated string tags = 5;
}
enum ProductStatus {
PRODUCT_STATUS_UNSPECIFIED = 0;
PRODUCT_STATUS_ACTIVE = 1;
PRODUCT_STATUS_DISCONTINUED = 2;
}
service Catalog {
rpc GetProduct(GetProductRequest) returns (Product);
}build.rs
The build script also writes a file descriptor set (the compiled schema), which reflection serves later.
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
tonic_build::configure()
.file_descriptor_set_path(out_dir.join("catalog_descriptor.bin"))
.compile_protos(&["proto/catalog.proto"], &["proto"])?;
Ok(())
}src/lib.rs
The service holds an in-memory product map. find holds the lookup, so gRPC and REST handlers can share it.
use std::{collections::HashMap, sync::Arc};
use pb::{catalog_server::Catalog, GetProductRequest, Product, ProductStatus};
use tonic::{Request, Response, Status};
pub mod pb {
tonic::include_proto!("storefront.catalog.v1");
pub const FILE_DESCRIPTOR_SET: &[u8] =
tonic::include_file_descriptor_set!("catalog_descriptor");
}
#[derive(Clone)]
pub struct CatalogService {
products: Arc<HashMap<i64, Product>>,
}
impl CatalogService {
pub fn seeded() -> Self {
let mut products = HashMap::new();
products.insert(
1,
Product {
id: 1,
name: "Notebook".into(),
price_cents: 450,
status: ProductStatus::Active as i32,
tags: vec!["stationery".into()],
},
);
products.insert(
2,
Product {
id: 2,
name: "Pen".into(),
price_cents: 120,
status: ProductStatus::Active as i32,
tags: vec!["stationery".into(), "writing".into()],
},
);
Self { products: Arc::new(products) }
}
pub fn find(&self, id: i64) -> Option<Product> {
self.products.get(&id).cloned()
}
}
#[tonic::async_trait]
impl Catalog for CatalogService {
async fn get_product(
&self,
request: Request<GetProductRequest>,
) -> Result<Response<Product>, Status> {
let id = request.into_inner().id;
self.find(id)
.map(Response::new)
.ok_or_else(|| Status::not_found(format!("product {id} not found")))
}
}src/main.rs
use std::net::SocketAddr;
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use catalog_service::{
pb::{catalog_server::CatalogServer, FILE_DESCRIPTOR_SET},
CatalogService,
};
use serde_json::{json, Value};
use tonic::service::Routes;
use tonic_web::GrpcWebLayer;
use tower_http::cors::{Any, CorsLayer};
async fn healthz() -> &'static str {
"ok"
}
async fn get_product(
State(catalog): State<CatalogService>,
Path(id): Path<i64>,
) -> Result<Json<Value>, StatusCode> {
let product = catalog.find(id).ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(json!({
"id": product.id,
"name": product.name,
"price_cents": product.price_cents,
})))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = std::env::var("LISTEN")
.unwrap_or_else(|_| "127.0.0.1:8080".into())
.parse()?;
let catalog = CatalogService::seeded();
let (reporter, health_service) = tonic_health::server::health_reporter();
reporter
.set_serving::<CatalogServer<CatalogService>>()
.await;
let reflection = tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET)
.build_v1()?;
let grpc = Routes::new(CatalogServer::new(catalog.clone()))
.add_service(health_service)
.add_service(reflection)
.into_axum_router()
.layer(GrpcWebLayer::new())
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
.expose_headers(Any),
);
let rest = Router::new()
.route("/healthz", get(healthz))
.route("/products/{id}", get(get_product))
.with_state(catalog);
let app = rest.merge(grpc);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}tests/e2e.rs
use catalog_service::{
pb::{catalog_client::CatalogClient, catalog_server::CatalogServer, GetProductRequest},
CatalogService,
};
use tokio_stream::wrappers::TcpListenerStream;
use tonic::{transport::Server, Code};
async fn start_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
Server::builder()
.add_service(CatalogServer::new(CatalogService::seeded()))
.serve_with_incoming(TcpListenerStream::new(listener))
.await
.unwrap();
});
format!("http://{addr}")
}
#[tokio::test]
async fn returns_seeded_product() {
let url = start_server().await;
let mut client = CatalogClient::connect(url).await.unwrap();
let product = client
.get_product(GetProductRequest { id: 1 })
.await
.unwrap()
.into_inner();
assert_eq!(product.name, "Notebook");
}
#[tokio::test]
async fn unknown_product_is_not_found() {
let url = start_server().await;
let mut client = CatalogClient::connect(url).await.unwrap();
let error = client
.get_product(GetProductRequest { id: 99 })
.await
.unwrap_err();
assert_eq!(error.code(), Code::NotFound);
}src/bin/plain_server.rs
use std::net::SocketAddr;
use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::Server;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = std::env::var("LISTEN")
.unwrap_or_else(|_| "127.0.0.1:50051".into())
.parse()?;
let (reporter, health_service) = tonic_health::server::health_reporter();
reporter
.set_serving::<CatalogServer<CatalogService>>()
.await;
Server::builder()
.add_service(health_service)
.add_service(CatalogServer::new(CatalogService::seeded()))
.serve(addr)
.await?;
Ok(())
}src/bin/tls_server.rs
use catalog_service::{pb::catalog_server::CatalogServer, CatalogService};
use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cert = std::fs::read_to_string("certs/server.pem")?;
let key = std::fs::read_to_string("certs/server.key")?;
let client_ca = std::fs::read_to_string("certs/ca.pem")?;
let tls = ServerTlsConfig::new()
.identity(Identity::from_pem(cert, key))
.client_ca_root(Certificate::from_pem(client_ca));
Server::builder()
.tls_config(tls)?
.add_service(CatalogServer::new(CatalogService::seeded()))
.serve("127.0.0.1:50443".parse()?)
.await?;
Ok(())
}src/bin/tls_client.rs
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let ca = std::fs::read_to_string("certs/ca.pem")?;
let cert = std::fs::read_to_string("certs/client.pem")?;
let key = std::fs::read_to_string("certs/client.key")?;
let tls = ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(ca))
.domain_name("localhost")
.identity(Identity::from_pem(cert, key));
let channel = Channel::from_static("https://localhost:50443")
.tls_config(tls)?
.connect()
.await?;
let mut client = CatalogClient::new(channel);
let product = client
.get_product(GetProductRequest { id: 1 })
.await?
.into_inner();
println!("{} {}", product.name, product.price_cents);
Ok(())
}src/bin/balanced_client.rs
use catalog_service::pb::{catalog_client::CatalogClient, GetProductRequest};
use tonic::transport::{Channel, Endpoint};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let endpoints = [
"http://127.0.0.1:50051",
"http://127.0.0.1:50052",
"http://127.0.0.1:50053",
]
.into_iter()
.map(Endpoint::from_static);
let channel = Channel::balance_list(endpoints);
let mut client = CatalogClient::new(channel);
for _ in 0..6 {
client.get_product(GetProductRequest { id: 1 }).await?;
}
println!("6 calls succeeded");
Ok(())
}Run
Requires protoc and grpcurl. Run cargo test for the tests and cargo run for the service. The extra binaries run as described in Examples 1 to 3 (cargo run --bin plain_server, and so on). Requests to the service:
curl -s http://127.0.0.1:8080/healthzokcurl -s http://127.0.0.1:8080/products/1{"id":1,"name":"Notebook","price_cents":450}grpcurl -plaintext -d '{"id": 1}' 127.0.0.1:8080 storefront.catalog.v1.Catalog/GetProduct{
"id": "1",
"name": "Notebook",
"priceCents": "450",
"status": "PRODUCT_STATUS_ACTIVE",
"tags": [
"stationery"
]
}grpcurl -plaintext -d '{"service": "storefront.catalog.v1.Catalog"}' 127.0.0.1:8080 grpc.health.v1.Health/Check{
"status": "SERVING"
}grpcurl -plaintext -d '{"id": 99}' 127.0.0.1:8080 storefront.catalog.v1.Catalog/GetProductERROR:
Code: NotFound
Message: product 99 not foundgrpcurl renders 64-bit integers as JSON strings, as shown.
Summary
- TLS needs a server identity and a trusted CA on the client. mTLS adds a client identity and a client CA on the server.
tonic-healthimplements the health service.set_servingandset_not_servingchange the reported state.tonic-reflectionserves the descriptor set thattonic-buildwrites.Channel::balance_listbalances calls across a fixed endpoint list.tonic-webplus CORS lets browsers call unary and server-streaming methods.Routes::into_axum_routermerges gRPC services with REST routes.- Tests bind
127.0.0.1:0and useserve_with_incoming.
