Files
notifier/docs/GRPC_CONNECTION_OPTIMIZATION.md

608 lines
18 KiB
Markdown

# gRPC Connection Optimization Guide
## TL;DR
**Yes, you can and should use long-lived gRPC connections!**
- ✅ Single connection can handle thousands of concurrent requests
- ✅ Authentication happens **per-request**, not per-connection
- ✅ Connection reuse eliminates TCP/TLS handshake overhead
- ✅ HTTP/2 multiplexing allows concurrent RPCs on one connection
- ✅ Built-in keepalive prevents connection timeouts
## How gRPC Authentication Works
### Key Point: Auth is Per-Request, Not Per-Connection
Your current implementation authenticates **each RPC call**, not the connection itself. This means:
1. Client establishes a **long-lived connection** (once)
2. Client sends **API key in metadata** with each request
3. Server validates the key for **every RPC call**
4. Connection stays open for multiple requests
```
Connection Lifecycle:
┌─────────────────────────────────────────────────────────────┐
│ TCP Connection (persistent) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TLS Handshake (once) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Request 1: Authorization: Bearer nk_abc... → Validated │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Request 2: Authorization: Bearer nk_abc... → Validated │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Request 3: Authorization: Bearer nk_abc... → Validated │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ... (connection stays open) │
└─────────────────────────────────────────────────────────────┘
```
### Authentication Overhead Analysis
**Per-Connection (One-Time):**
- TCP handshake: ~1-2ms (3-way handshake)
- TLS handshake: ~5-10ms (certificate exchange, key agreement)
- **Total: ~10-15ms once**
**Per-Request (Every Call):**
- API key validation: ~0.1-1ms (in-memory lookup)
- Rate limit check: ~0.1ms (in-memory counter)
- **Total: ~0.2-1ms per request**
**With Connection Reuse:**
- First request: 10-15ms (connection) + 1ms (auth) = **11-16ms**
- Subsequent requests: **1ms** (only auth, no connection setup)
**Without Connection Reuse (reconnecting each time):**
- Every request: 10-15ms (connection) + 1ms (auth) = **11-16ms**
**Savings: 10-15ms per request after the first one!**
## Recommended Client Pattern
### Basic Long-Lived Connection
```go
package main
import (
"context"
"log"
"time"
pb "github.com/igodwin/notifier/api/grpc/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
type NotifierClient struct {
conn *grpc.ClientConn
client pb.NotifierServiceClient
apiKey string
}
func NewNotifierClient(address, apiKey string) (*NotifierClient, error) {
// Establish long-lived connection
conn, err := grpc.Dial(address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
// Connection pool settings
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(4*1024*1024), // 4MB
grpc.MaxCallSendMsgSize(4*1024*1024),
),
// Keepalive settings
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second, // Send keepalive ping every 10s
Timeout: 3 * time.Second, // Wait 3s for ping ack
PermitWithoutStream: true, // Allow pings when no streams
}),
)
if err != nil {
return nil, err
}
return &NotifierClient{
conn: conn,
client: pb.NewNotifierServiceClient(conn),
apiKey: apiKey,
}, nil
}
func (nc *NotifierClient) SendNotification(ctx context.Context, req *pb.SendNotificationRequest) (*pb.SendNotificationResponse, error) {
// Add API key to metadata for THIS request
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+nc.apiKey)
// Make RPC call - reuses existing connection
return nc.client.SendNotification(ctx, req)
}
func (nc *NotifierClient) Close() error {
return nc.conn.Close()
}
func main() {
// Create client with long-lived connection
client, err := NewNotifierClient("notifier-grpc:50051", "nk_your_api_key")
if err != nil {
log.Fatal(err)
}
defer client.Close() // Close when application exits
// Reuse client for multiple requests
for i := 0; i < 1000; i++ {
ctx := context.Background()
resp, err := client.SendNotification(ctx, &pb.SendNotificationRequest{
Type: pb.NotificationType_NOTIFICATION_TYPE_EMAIL,
Subject: "Test notification",
Body: "This is a test",
Recipients: []string{"user@example.com"},
})
if err != nil {
log.Printf("Request %d failed: %v", i, err)
continue
}
log.Printf("Request %d succeeded: %s", i, resp.NotificationId)
}
// Connection is closed when main() exits
}
```
### Advanced: Connection Pool for High Concurrency
For extremely high throughput, you can create multiple connections:
```go
package main
import (
"context"
"sync"
pb "github.com/igodwin/notifier/api/grpc/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
)
type NotifierPool struct {
connections []*grpc.ClientConn
clients []pb.NotifierServiceClient
apiKey string
current uint32
mu sync.Mutex
}
func NewNotifierPool(address, apiKey string, poolSize int) (*NotifierPool, error) {
pool := &NotifierPool{
connections: make([]*grpc.ClientConn, poolSize),
clients: make([]pb.NotifierServiceClient, poolSize),
apiKey: apiKey,
}
// Create multiple connections
for i := 0; i < poolSize; i++ {
conn, err := grpc.Dial(address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 3 * time.Second,
PermitWithoutStream: true,
}),
)
if err != nil {
// Clean up any connections already created
pool.Close()
return nil, err
}
pool.connections[i] = conn
pool.clients[i] = pb.NewNotifierServiceClient(conn)
}
return pool, nil
}
func (np *NotifierPool) getClient() pb.NotifierServiceClient {
// Round-robin connection selection
np.mu.Lock()
defer np.mu.Unlock()
idx := np.current % uint32(len(np.clients))
np.current++
return np.clients[idx]
}
func (np *NotifierPool) SendNotification(ctx context.Context, req *pb.SendNotificationRequest) (*pb.SendNotificationResponse, error) {
// Add API key to metadata
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+np.apiKey)
// Get a client from pool (round-robin)
client := np.getClient()
return client.SendNotification(ctx, req)
}
func (np *NotifierPool) Close() error {
var firstErr error
for _, conn := range np.connections {
if conn != nil {
if err := conn.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
// Usage
func main() {
// Create pool with 4 connections
pool, err := NewNotifierPool("notifier-grpc:50051", "nk_your_api_key", 4)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
// Use pool concurrently from multiple goroutines
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
resp, err := pool.SendNotification(context.Background(), &pb.SendNotificationRequest{
Type: pb.NotificationType_NOTIFICATION_TYPE_EMAIL,
Subject: fmt.Sprintf("Notification %d", id),
Body: "Test",
Recipients: []string{"user@example.com"},
})
if err != nil {
log.Printf("Request %d failed: %v", id, err)
return
}
log.Printf("Request %d succeeded: %s", id, resp.NotificationId)
}(i)
}
wg.Wait()
}
```
## Keepalive Configuration
### Why Keepalive Matters
In Kubernetes, idle connections may be terminated by:
- Load balancers (after 60-600 seconds)
- Network proxies
- Firewalls with connection tracking
**Solution:** Send periodic keepalive pings
### Client-Side Keepalive
```go
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second, // Send ping every 10s of inactivity
Timeout: 3 * time.Second, // Wait 3s for ping response
PermitWithoutStream: true, // Send pings even when no active RPCs
})
```
### Server-Side Keepalive (Already Configured in Your Server)
Add to `cmd/server/main.go` in `startGRPCServer()`:
```go
serverOpts = append(serverOpts,
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Minute, // Close idle connections after 15m
MaxConnectionAge: 30 * time.Minute, // Force close after 30m
MaxConnectionAgeGrace: 5 * time.Second, // Allow 5s for RPCs to complete
Time: 5 * time.Second, // Send ping if idle for 5s
Timeout: 1 * time.Second, // Wait 1s for ping response
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 5 * time.Second, // Don't allow pings more often than 5s
PermitWithoutStream: true, // Allow pings when no streams
}),
)
```
## Performance Comparison
### Scenario 1: Short-Lived Connections (Creating new connection for each request)
```
Request 1: 15ms (10ms connect + 5ms TLS + 1ms auth + 0.5ms RPC)
Request 2: 15ms (10ms connect + 5ms TLS + 1ms auth + 0.5ms RPC)
Request 3: 15ms (10ms connect + 5ms TLS + 1ms auth + 0.5ms RPC)
...
1000 requests: ~15,000ms (15 seconds)
```
### Scenario 2: Long-Lived Connection (Recommended)
```
Request 1: 15ms (10ms connect + 5ms TLS + 1ms auth + 0.5ms RPC)
Request 2: 1.5ms (1ms auth + 0.5ms RPC)
Request 3: 1.5ms (1ms auth + 0.5ms RPC)
...
1000 requests: ~1,515ms (1.5 seconds)
```
**Performance Improvement: 10x faster! (15s → 1.5s)**
### Scenario 3: Connection Pool with 4 Connections
```
First 4 requests: 15ms each (connection setup)
Remaining 996: 1.5ms each (reuse connections)
1000 requests: ~1,554ms (1.5 seconds)
Handles concurrent load better
```
## Best Practices
### 1. Connection Lifecycle Management
```go
// Application-scoped client (singleton)
var (
notifierClient *NotifierClient
once sync.Once
)
func GetNotifierClient() *NotifierClient {
once.Do(func() {
client, err := NewNotifierClient(
os.Getenv("NOTIFIER_ADDRESS"),
os.Getenv("NOTIFIER_API_KEY"),
)
if err != nil {
log.Fatalf("Failed to create notifier client: %v", err)
}
notifierClient = client
})
return notifierClient
}
// In main():
func main() {
client := GetNotifierClient()
defer client.Close()
// ... run application ...
}
```
### 2. Context with Timeout
Always use context with timeout to prevent hanging requests:
```go
func (nc *NotifierClient) SendNotification(ctx context.Context, req *pb.SendNotificationRequest) (*pb.SendNotificationResponse, error) {
// Set timeout for this request
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Add API key
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+nc.apiKey)
return nc.client.SendNotification(ctx, req)
}
```
### 3. Health Checks
Periodically verify the connection is healthy:
```go
func (nc *NotifierClient) HealthCheck(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// Add API key
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+nc.apiKey)
_, err := nc.client.HealthCheck(ctx, &pb.HealthCheckRequest{})
return err
}
// In background goroutine:
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := client.HealthCheck(context.Background()); err != nil {
log.Printf("Health check failed: %v", err)
// Consider reconnecting or alerting
}
}
}()
```
### 4. Graceful Reconnection
Handle connection failures gracefully:
```go
type ResilientNotifierClient struct {
address string
apiKey string
client *NotifierClient
mu sync.RWMutex
}
func (rnc *ResilientNotifierClient) SendNotification(ctx context.Context, req *pb.SendNotificationRequest) (*pb.SendNotificationResponse, error) {
rnc.mu.RLock()
client := rnc.client
rnc.mu.RUnlock()
resp, err := client.SendNotification(ctx, req)
if err != nil && isConnectionError(err) {
// Try to reconnect
log.Printf("Connection error, attempting reconnect: %v", err)
if err := rnc.reconnect(); err != nil {
return nil, fmt.Errorf("reconnection failed: %w", err)
}
// Retry request with new connection
rnc.mu.RLock()
client = rnc.client
rnc.mu.RUnlock()
return client.SendNotification(ctx, req)
}
return resp, err
}
func (rnc *ResilientNotifierClient) reconnect() error {
rnc.mu.Lock()
defer rnc.mu.Unlock()
// Close old connection
if rnc.client != nil {
rnc.client.Close()
}
// Create new connection
client, err := NewNotifierClient(rnc.address, rnc.apiKey)
if err != nil {
return err
}
rnc.client = client
return nil
}
```
## Kubernetes Deployment Considerations
### 1. Service Configuration
Your services are already correctly configured for long-lived connections:
```yaml
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: notifier-grpc
spec:
type: ClusterIP # ✅ Good: Stable internal endpoint
ports:
- port: 50051
targetPort: grpc
protocol: TCP
```
### 2. Client Connection String
```go
// Within same namespace
client, _ := NewNotifierClient("notifier-grpc:50051", apiKey)
// From different namespace
client, _ := NewNotifierClient("notifier-grpc.default.svc.cluster.local:50051", apiKey)
```
### 3. Load Balancing
Kubernetes service provides **connection-level** load balancing. For better **request-level** load balancing with long-lived connections, consider:
**Option A: Client-Side Load Balancing**
```go
import "google.golang.org/grpc/resolver"
conn, err := grpc.Dial(
"dns:///notifier-grpc:50051", // DNS resolver
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
```
**Option B: Connection Pool** (shown earlier)
## Rate Limiting Considerations
### Your Current Implementation
The rate limiter checks on **every request** (in `grpc_middleware.go:46-50`):
```go
allowed, err := m.store.CheckRateLimit(apiKey)
if err != nil || !allowed {
return nil, status.Error(codes.ResourceExhausted, "Rate limit exceeded")
}
```
### Impact with Long-Lived Connections
**No negative impact!** Rate limiting works the same:
- Each RPC call is checked independently
- Connection reuse doesn't bypass rate limits
- Rate limit is per API key, not per connection
## Summary
### ✅ DO: Use Long-Lived Connections
```go
// Create once at application startup
client, _ := NewNotifierClient("notifier-grpc:50051", apiKey)
defer client.Close()
// Reuse for all requests
for {
client.SendNotification(ctx, req)
}
```
**Benefits:**
- 10x faster (eliminates connection setup overhead)
- Lower latency (1.5ms vs 15ms per request)
- Fewer resources (one connection vs. thousands)
- Better throughput (HTTP/2 multiplexing)
- Automatic keepalive prevents timeouts
### ❌ DON'T: Create Connection Per Request
```go
// BAD: Don't do this!
for {
client, _ := NewNotifierClient("notifier-grpc:50051", apiKey)
client.SendNotification(ctx, req)
client.Close()
}
```
**Problems:**
- Slow (15ms per request)
- Wasteful (repeated TCP/TLS handshakes)
- Resource-intensive (thousands of connections)
### Authentication Still Happens Per-Request
- API key sent in metadata with every RPC
- Server validates on every call
- No security trade-off
- Just eliminates connection setup overhead
**You get both: Maximum performance AND full security!**