This guide covers deploying session clustering in a production multi-node environment.
flowchart TB
LB["Load Balancer"]
subgraph Nodes["Application Nodes"]
N1["Node 1<br/>node-id: app-1"]
N2["Node 2<br/>node-id: app-2"]
N3["Node 3<br/>node-id: app-3"]
end
subgraph RedisCluster["Redis"]
Primary[("Primary")]
Replica1[("Replica")]
Replica2[("Replica")]
end
LB --> N1
LB --> N2
LB --> N3
N1 <--> Primary
N2 <--> Primary
N3 <--> Primary
Primary --> Replica1
Primary --> Replica2
- Redis 5.0+ (for reliable Pub/Sub)
- All application nodes can reach Redis
- Unique node identifiers for each instance
# docker-compose.yml
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
app1:
build: .
environment:
- NODE_ID=app-node-1
- REDIS_URL=redis://redis:6379
- SESSION_SECRET=${SESSION_SECRET}
depends_on:
- redis
app2:
build: .
environment:
- NODE_ID=app-node-2
- REDIS_URL=redis://redis:6379
- SESSION_SECRET=${SESSION_SECRET}
depends_on:
- redis
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
- app1
- app2
volumes:
redis_data:# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: app
image: myapp:latest
env:
- name: NODE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name # Uses pod name
- name: REDIS_URL
value: "redis://redis-master:6379"
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
name: app-secrets
key: session-secret
---
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
selector:
app: myapp
ports:
- port: 80
targetPort: 8080# config/session.cr
Session.configure do |config|
config.secret = ENV["SESSION_SECRET"]
config.timeout = 24.hours
# Clustering configuration
config.cluster.enabled = true
config.cluster.node_id = ENV["NODE_ID"]? || ENV["HOSTNAME"]? || UUID.random.to_s
config.cluster.channel = "#{ENV["APP_NAME"]?}:session:invalidate"
config.cluster.local_cache_ttl = 30.seconds
config.cluster.local_cache_max_size = 50_000
# Redis connection
redis_url = ENV["REDIS_URL"]? || "redis://localhost:6379"
redis = Redis.new(url: redis_url)
config.store = Session::ClusteredRedisStore(UserSession).new(
client: redis
)
end# Explicit environment variable
config.cluster.node_id = ENV["NODE_ID"]# System hostname
config.cluster.node_id = System.hostname# Pod name from downward API
config.cluster.node_id = ENV["POD_NAME"]? || System.hostname# Combine multiple identifiers
config.cluster.node_id = "#{System.hostname}-#{Process.pid}-#{Time.utc.to_unix}"sequenceDiagram
participant User
participant Node1 as Node 1
participant Redis
participant Node2 as Node 2
participant Node3 as Node 3
User->>Node1: Login Request
Node1->>Redis: Store Session
Node1->>Redis: Publish: session_created
User->>Node2: Subsequent Request
Node2->>Node2: Check Local Cache (miss)
Node2->>Redis: Fetch Session
Node2->>Node2: Cache Session
User->>Node1: Logout Request
Node1->>Redis: Delete Session
Node1->>Redis: Publish: session_deleted
Redis-->>Node2: Invalidation Message
Redis-->>Node3: Invalidation Message
Node2->>Node2: Evict from Cache
Node3->>Node3: Evict from Cache
# Add health check endpoint
get "/health" do
store = Session.config.store.not_nil!.as(Session::ClusteredRedisStore(UserSession))
health = {
redis: store.healthy?,
coordinator_running: store.coordinator.running?,
cache_size: store.coordinator.local_cache.size,
}
if health[:redis] && health[:coordinator_running]
json health, status: 200
else
json health, status: 503
end
endlivenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10# Handle shutdown signals
Signal::INT.trap do
Log.info { "Shutting down..." }
# Stop the cluster coordinator
if store = Session.config.store
if clustered = store.as?(Session::ClusteredRedisStore(UserSession))
clustered.shutdown
end
end
exit 0
end
Signal::TERM.trap do
# Same as above
endgraph LR
subgraph Application Metrics
A1["cache_hit_rate"]
A2["cache_size"]
A3["cache_evictions"]
end
subgraph Redis Metrics
R1["connected_clients"]
R2["pubsub_channels"]
R3["pubsub_patterns"]
end
subgraph Cluster Metrics
C1["messages_published"]
C2["messages_received"]
C3["invalidations_processed"]
end
# Custom metrics backend
class PrometheusSessionMetrics < Session::Metrics::Backend
def increment(name : String, tags : Hash(String, String) = {} of String => String)
labels = tags.map { |k, v| "#{k}=\"#{v}\"" }.join(",")
# Push to Prometheus
end
end
Session.configure do |config|
config.metrics_backend = PrometheusSessionMetrics.new
end- Check Redis connectivity from all nodes
- Verify channel name is identical across nodes
- Check node_id is unique per node
- Verify Pub/Sub is working:
# In one terminal
redis-cli SUBSCRIBE session:cluster:invalidate
# In another terminal
redis-cli PUBLISH session:cluster:invalidate '{"test": true}'- Increase cache size if evictions are high
- Increase TTL if sessions aren't changing frequently
- Check invalidation frequency - too many invalidations?
- Reduce cache size:
local_cache_max_size - Reduce TTL: Forces more frequent eviction
- Monitor with:
store.cache_stats
- Enable circuit breaker to prevent cascading failures
- Configure retry logic for transient errors
- Use connection pooling for high-traffic apps
Session.configure do |config|
config.circuit_breaker_enabled = true
config.enable_retry = true
endEnsure node IDs persist across restarts to avoid duplicate subscriptions:
# Good: Environment variable or hostname
config.cluster.node_id = ENV["NODE_ID"]
# Avoid: Random UUID on each start
config.cluster.node_id = UUID.random.to_s # New ID each restart# Calculate based on peak concurrent sessions
peak_sessions = 10_000
cache_multiplier = 1.5 # Some headroom
config.cluster.local_cache_max_size = (peak_sessions * cache_multiplier).to_i# Dedicated Redis instance for sessions
session_redis = Redis.new(host: "session-redis.internal")config.encrypt_redis_data = true# Application continues working even if clustering fails
begin
store = Session::ClusteredRedisStore(UserSession).new(client: redis)
rescue ex
Log.error { "Failed to initialize clustered store: #{ex.message}" }
store = Session::RedisStore(UserSession).new(client: redis)
end