356 lines
9.6 KiB
Markdown
356 lines
9.6 KiB
Markdown
# Redis Migration Guide
|
||
|
||
## Overview
|
||
|
||
This guide covers migrating from in-memory KV storage to Redis for multi-instance SaaS deployments. The current in-memory session storage works well for single-instance deployments but requires centralized storage for horizontal scaling.
|
||
|
||
### When to Migrate
|
||
|
||
**Current Setup (Sufficient For):**
|
||
- Single-instance deployments
|
||
- Development environments
|
||
- Small-scale production deployments
|
||
|
||
**Redis Required For:**
|
||
- Multiple server instances behind load balancer
|
||
- Container orchestration (Kubernetes, Docker Swarm)
|
||
- High-availability SaaS deployments
|
||
- Session persistence across server restarts
|
||
|
||
## What Needs Migration
|
||
|
||
### Critical Data (Must Migrate)
|
||
- **Sessions** (`sessionsKV`): Essential for authentication across instances
|
||
- **CSRF tokens**: Stored within session objects
|
||
- **Rate limiting data**: Currently in-memory Map in `rate-limiter.ts`
|
||
|
||
### Optional Data (Can Remain File-based)
|
||
- Bookings, treatments, reviews, gallery photos
|
||
- Can stay in file-based KV for now
|
||
- Migrate later if performance becomes an issue
|
||
|
||
## Redis Setup
|
||
|
||
### Installation Options
|
||
|
||
#### Self-hosted Redis
|
||
```bash
|
||
# Docker
|
||
docker run -d -p 6379:6379 redis:alpine
|
||
|
||
# Ubuntu/Debian
|
||
sudo apt update
|
||
sudo apt install redis-server
|
||
|
||
# macOS (Homebrew)
|
||
brew install redis
|
||
brew services start redis
|
||
```
|
||
|
||
#### Managed Services
|
||
- **Redis Cloud**: Managed Redis service
|
||
- **AWS ElastiCache**: Redis-compatible cache
|
||
- **Azure Cache for Redis**: Microsoft's managed service
|
||
- **DigitalOcean Managed Databases**: Redis option available
|
||
|
||
### Configuration
|
||
|
||
Add to `.env`:
|
||
```bash
|
||
# Redis Configuration (required for multi-instance deployments)
|
||
REDIS_URL=redis://localhost:6379
|
||
REDIS_TLS_ENABLED=false # Set to true for production
|
||
REDIS_PASSWORD=your_redis_password # Optional
|
||
```
|
||
|
||
## Code Changes
|
||
|
||
### 1. Install Redis Client
|
||
|
||
```bash
|
||
pnpm add ioredis
|
||
pnpm add -D @types/ioredis
|
||
```
|
||
|
||
### 2. Create Redis KV Adapter
|
||
|
||
Create `src/server/lib/create-redis-kv.ts`:
|
||
|
||
```typescript
|
||
import Redis from 'ioredis';
|
||
|
||
interface RedisKVStore<T> {
|
||
getItem(key: string): Promise<T | null>;
|
||
setItem(key: string, value: T): Promise<void>;
|
||
removeItem(key: string): Promise<void>;
|
||
getAllItems(): Promise<T[]>;
|
||
subscribe(): AsyncIterable<void>;
|
||
}
|
||
|
||
export function createRedisKV<T>(namespace: string): RedisKVStore<T> {
|
||
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||
retryDelayOnFailover: 100,
|
||
enableReadyCheck: false,
|
||
maxRetriesPerRequest: null,
|
||
lazyConnect: true,
|
||
});
|
||
|
||
const prefix = `${namespace}:`;
|
||
|
||
return {
|
||
async getItem(key: string): Promise<T | null> {
|
||
const value = await redis.get(`${prefix}${key}`);
|
||
return value ? JSON.parse(value) : null;
|
||
},
|
||
|
||
async setItem(key: string, value: T): Promise<void> {
|
||
await redis.set(`${prefix}${key}`, JSON.stringify(value));
|
||
},
|
||
|
||
async removeItem(key: string): Promise<void> {
|
||
await redis.del(`${prefix}${key}`);
|
||
},
|
||
|
||
async getAllItems(): Promise<T[]> {
|
||
const keys = await redis.keys(`${prefix}*`);
|
||
if (keys.length === 0) return [];
|
||
|
||
const values = await redis.mget(...keys);
|
||
return values
|
||
.filter(v => v !== null)
|
||
.map(v => JSON.parse(v!));
|
||
},
|
||
|
||
async* subscribe(): AsyncIterable<void> {
|
||
const pubsub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
|
||
await pubsub.subscribe(`${namespace}:changes`);
|
||
|
||
for await (const message of pubsub) {
|
||
if (message[0] === 'message' && message[1] === `${namespace}:changes`) {
|
||
yield;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
// Helper to notify subscribers of changes
|
||
export async function notifyRedisChanges(namespace: string): Promise<void> {
|
||
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
|
||
await redis.publish(`${namespace}:changes`, 'update');
|
||
await redis.quit();
|
||
}
|
||
```
|
||
|
||
### 3. Update Session Storage
|
||
|
||
In `src/server/lib/auth.ts`:
|
||
|
||
```typescript
|
||
import { createRedisKV } from './create-redis-kv.js';
|
||
|
||
// Replace this line:
|
||
// export const sessionsKV = createKV<Session>("sessions");
|
||
|
||
// With this:
|
||
export const sessionsKV = createRedisKV<Session>("sessions");
|
||
```
|
||
|
||
### 4. Update Rate Limiter
|
||
|
||
In `src/server/lib/rate-limiter.ts`:
|
||
|
||
```typescript
|
||
import Redis from 'ioredis';
|
||
|
||
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||
retryDelayOnFailover: 100,
|
||
enableReadyCheck: false,
|
||
maxRetriesPerRequest: null,
|
||
lazyConnect: true,
|
||
});
|
||
|
||
export async function checkBookingRateLimit(
|
||
email?: string,
|
||
ip?: string
|
||
): Promise<{ allowed: boolean; resetTime?: number }> {
|
||
const now = Date.now();
|
||
const windowMs = 15 * 60 * 1000; // 15 minutes
|
||
const maxRequests = 3;
|
||
|
||
const results = await Promise.all([
|
||
email ? checkRateLimit(`booking:email:${email}`, maxRequests, windowMs) : { allowed: true },
|
||
ip ? checkRateLimit(`booking:ip:${ip}`, maxRequests, windowMs) : { allowed: true }
|
||
]);
|
||
|
||
return {
|
||
allowed: results.every(r => r.allowed),
|
||
resetTime: results.find(r => !r.allowed)?.resetTime
|
||
};
|
||
}
|
||
|
||
async function checkRateLimit(
|
||
key: string,
|
||
maxRequests: number,
|
||
windowMs: number
|
||
): Promise<{ allowed: boolean; resetTime?: number }> {
|
||
const now = Date.now();
|
||
const windowStart = now - windowMs;
|
||
|
||
// Use Redis sorted set for sliding window
|
||
const pipeline = redis.pipeline();
|
||
|
||
// Remove old entries
|
||
pipeline.zremrangebyscore(key, 0, windowStart);
|
||
|
||
// Count current entries
|
||
pipeline.zcard(key);
|
||
|
||
// Add current request
|
||
pipeline.zadd(key, now, `${now}-${Math.random()}`);
|
||
|
||
// Set expiry
|
||
pipeline.expire(key, Math.ceil(windowMs / 1000));
|
||
|
||
const results = await pipeline.exec();
|
||
const currentCount = results?.[1]?.[1] as number || 0;
|
||
|
||
return {
|
||
allowed: currentCount < maxRequests,
|
||
resetTime: currentCount >= maxRequests ? windowStart + windowMs : undefined
|
||
};
|
||
}
|
||
```
|
||
|
||
### 5. Environment Configuration
|
||
|
||
Update `.env.example`:
|
||
|
||
```bash
|
||
# Redis Configuration (optional - required for multi-instance SaaS deployments)
|
||
# For single-instance deployments, the default in-memory storage is sufficient
|
||
# See docs/redis-migration.md for migration guide
|
||
REDIS_URL=redis://localhost:6379
|
||
REDIS_TLS_ENABLED=false # Enable for production
|
||
REDIS_PASSWORD=your_redis_password # Optional
|
||
```
|
||
|
||
## Migration Strategy
|
||
|
||
### Development Testing
|
||
|
||
1. **Start Redis locally**:
|
||
```bash
|
||
docker run -d -p 6379:6379 redis:alpine
|
||
```
|
||
|
||
2. **Test session functionality**:
|
||
- Login/logout
|
||
- Session validation
|
||
- CSRF token generation/validation
|
||
- Session rotation
|
||
|
||
3. **Test rate limiting**:
|
||
- Verify rate limit enforcement
|
||
- Check sliding window behavior
|
||
|
||
4. **Simulate multi-instance**:
|
||
- Run multiple server instances
|
||
- Verify session sharing works
|
||
|
||
### Staging Deployment
|
||
|
||
1. **Deploy Redis in staging**
|
||
2. **Run single instance first** to verify functionality
|
||
3. **Scale to multiple instances** and test session sharing
|
||
4. **Monitor Redis memory usage** and connection pool
|
||
|
||
### Production Rollout
|
||
|
||
#### Option A: Blue/Green Deployment
|
||
1. Deploy new version with Redis to green environment
|
||
2. Test thoroughly with production-like data
|
||
3. Switch traffic to green environment
|
||
4. **Note**: Existing sessions will be lost (users need to re-login)
|
||
|
||
#### Option B: Gradual Migration
|
||
1. Deploy Redis alongside existing system
|
||
2. Write to both stores temporarily (dual-write)
|
||
3. Read from Redis first, fallback to in-memory
|
||
4. After verification period, remove in-memory store
|
||
|
||
## Monitoring & Maintenance
|
||
|
||
### Key Metrics
|
||
|
||
- **Redis memory usage**: Monitor `used_memory` and `used_memory_peak`
|
||
- **Connection pool**: Track active connections and pool utilization
|
||
- **Session operations**: Monitor session creation/validation latency
|
||
- **Rate limit hits**: Track rate limit enforcement effectiveness
|
||
|
||
### Backup Strategy
|
||
|
||
- **Enable Redis persistence**: Configure RDB snapshots or AOF logging
|
||
- **Regular backups**: Backup Redis data regularly
|
||
- **Session data**: Ephemeral, but rate limit data should be backed up
|
||
|
||
### Scaling Considerations
|
||
|
||
- **Redis Cluster**: For horizontal scaling across multiple nodes
|
||
- **Redis Sentinel**: For high availability with automatic failover
|
||
- **Connection pooling**: Configure appropriate pool sizes
|
||
|
||
## Rollback Plan
|
||
|
||
If issues arise, you can revert to in-memory storage:
|
||
|
||
1. **Remove Redis imports** and revert to `createKV`
|
||
2. **Remove Redis environment variables**
|
||
3. **Redeploy** with in-memory storage
|
||
4. **Note**: All sessions will be lost (users need to re-login)
|
||
|
||
## Cost Considerations
|
||
|
||
### Redis Hosting Costs
|
||
|
||
- **Self-hosted**: Server costs + maintenance
|
||
- **Managed services**:
|
||
- Redis Cloud: ~$7/month for 30MB
|
||
- AWS ElastiCache: ~$15/month for t3.micro
|
||
- Azure Cache: ~$16/month for Basic tier
|
||
|
||
### Memory Requirements
|
||
|
||
**Session storage**: Minimal
|
||
- Example: 1000 concurrent users × 1KB per session = ~1MB
|
||
- CSRF tokens add minimal overhead
|
||
|
||
**Rate limiting**: Negligible
|
||
- Sliding window data is automatically cleaned up
|
||
- Minimal memory footprint per IP/email
|
||
|
||
## Alternative Solutions
|
||
|
||
### Sticky Sessions
|
||
- Load balancer routes user to same instance
|
||
- **Pros**: Simpler implementation
|
||
- **Cons**: Less resilient, harder to scale
|
||
|
||
### Database-backed Sessions
|
||
- Use PostgreSQL/MySQL instead of Redis
|
||
- **Pros**: No additional infrastructure
|
||
- **Cons**: Higher latency, more database load
|
||
|
||
### JWT Tokens
|
||
- Stateless authentication tokens
|
||
- **Pros**: No server-side session storage needed
|
||
- **Cons**: No server-side session invalidation, different security model
|
||
|
||
## References
|
||
|
||
- [Session Management Guide](session-management.md)
|
||
- [Rate Limiting Documentation](rate-limiting.md)
|
||
- [Redis Documentation](https://redis.io/documentation)
|
||
- [ioredis Library](https://github.com/luin/ioredis)
|
||
- [Redis Best Practices](https://redis.io/docs/manual/admin/)
|