コンテンツにスキップ

Upstash Redis 統合設計書

概要

Vendure における Upstash Redis の統合設計および設定方法について説明します。Redis は以下の用途で使用されます:

  1. セッション管理 - ユーザー認証セッションの保存
  2. BullMQ ジョブキュー - バックグラウンドタスクの処理
  3. キャッシュ戦略 - GraphQL クエリ結果や商品データのキャッシュ
  4. レート制限 - API 使用制限の管理

Upstash Redis セットアップ

1. Redis インスタンス作成

# Fly.io CLI で Upstash Redis を作成
flyctl redis create

# 選択項目:
# Organization: ritsubi
# Redis database name: ritsubi-vendure-redis
# Primary region: Tokyo (nrt)
# Eviction policy: noeviction (BullMQ 用に重要)
# Plan: Fixed $3 plan (3GB Max Data Size 推奨)

2. Redis 接続情報取得

# Redis接続情報を確認
flyctl redis status ritsubi-vendure-redis

# 接続情報をシークレットに設定
flyctl secrets set UPSTASH_REDIS_URL="redis://..." -a ritsubi-vendure
flyctl secrets set UPSTASH_REDIS_PASSWORD="..." -a ritsubi-vendure

Redis 接続設定

基本接続設定

// redis-config.ts
import { Redis } from 'ioredis';

export interface RedisConfig {
  host: string;
  port: number;
  password: string;
  keyPrefix?: string;
  ttl?: number;
  tls?: {
    rejectUnauthorized: boolean;
  };
}

export const createRedisConnection = (config: RedisConfig): Redis => {
  return new Redis({
    host: config.host,
    port: config.port,
    password: config.password,
    keyPrefix: config.keyPrefix,
    tls: config.tls || { rejectUnauthorized: false },
    retryDelayOnFailover: 1000,
    maxRetriesPerRequest: 3,
    lazyConnect: true,
    keepAlive: 30000,
    family: 4, // IPv4
    connectTimeout: 10000,
    commandTimeout: 5000,
  });
};

環境別設定

// redis-configs.ts
export const redisConfigs = {
  development: {
    host: 'localhost',
    port: 6379,
    password: '',
    keyPrefix: 'dev:',
  },

  staging: {
    host: process.env.UPSTASH_REDIS_HOST_STAGING!,
    port: 6379,
    password: process.env.UPSTASH_REDIS_PASSWORD_STAGING!,
    keyPrefix: 'staging:',
    tls: { rejectUnauthorized: false },
  },

  production: {
    host: process.env.UPSTASH_REDIS_HOST!,
    port: 6379,
    password: process.env.UPSTASH_REDIS_PASSWORD!,
    keyPrefix: 'prod:',
    tls: { rejectUnauthorized: false },
  },
};

export const getRedisConfig = (
  env: string = process.env.NODE_ENV || 'development',
) => {
  return redisConfigs[env as keyof typeof redisConfigs];
};

セッション管理設定

RedisSessionCacheStrategy 実装

// redis-session-cache-strategy.ts
import { CachedSession, Logger, SessionCacheStrategy } from '@vendure/core';
import { Redis } from 'ioredis';

export class RedisSessionCacheStrategy implements SessionCacheStrategy {
  private readonly logger = new Logger(RedisSessionCacheStrategy.name);
  private redis: Redis;

  constructor(
    private config: {
      redisOptions: RedisConfig;
      ttl?: number; // セッション有効期限(秒)
    },
  ) {
    this.redis = createRedisConnection(config.redisOptions);
  }

  async get(sessionId: string): Promise<CachedSession | undefined> {
    try {
      const key = this.getSessionKey(sessionId);
      const data = await this.redis.get(key);

      if (!data) {
        return undefined;
      }

      return JSON.parse(data) as CachedSession;
    } catch (error) {
      this.logger.error(`Failed to get session ${sessionId}:`, error);
      return undefined;
    }
  }

  async set(session: CachedSession): Promise<void> {
    try {
      const key = this.getSessionKey(session.id);
      const data = JSON.stringify(session);
      const ttl = this.config.ttl || 7 * 24 * 60 * 60; // 7日間デフォルト

      await this.redis.setex(key, ttl, data);
    } catch (error) {
      this.logger.error(`Failed to set session ${session.id}:`, error);
      throw error;
    }
  }

  async delete(sessionId: string): Promise<void> {
    try {
      const key = this.getSessionKey(sessionId);
      await this.redis.del(key);
    } catch (error) {
      this.logger.error(`Failed to delete session ${sessionId}:`, error);
      throw error;
    }
  }

  async clear(): Promise<void> {
    try {
      const pattern = this.getSessionKey('*');
      const keys = await this.redis.keys(pattern);

      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    } catch (error) {
      this.logger.error('Failed to clear sessions:', error);
      throw error;
    }
  }

  private getSessionKey(sessionId: string): string {
    return `session:${sessionId}`;
  }
}

BullMQ ジョブキュー設定

BullMQ Redis 設定

// bullmq-redis-strategy.ts
import { BullMQJobQueueStrategy } from '@vendure/job-queue-plugin/package/bullmq';
import { ConnectionOptions } from 'bullmq';

export const createBullMQConfig = (): ConnectionOptions => {
  return {
    host: process.env.UPSTASH_REDIS_HOST!,
    port: 6379,
    password: process.env.UPSTASH_REDIS_PASSWORD!,
    tls: { rejectUnauthorized: false },

    // BullMQ 固有設定
    maxRetriesPerRequest: null, // 重要: null に設定
    retryDelayOnFailover: 1000,
    enableReadyCheck: false,
    maxRetriesPerRequest: null,

    // 接続プール設定
    lazyConnect: true,
    keepAlive: 30000,
    family: 4,
  };
};

export const bullMQJobQueueStrategy = new BullMQJobQueueStrategy({
  connection: createBullMQConfig(),
});

ジョブキュー設定

// job-queue-config.ts
export const jobQueueConfig = {
  // デフォルト設定
  defaultJobOptions: {
    removeOnComplete: 10, // 完了ジョブを10件まで保持
    removeOnFail: 50, // 失敗ジョブを50件まで保持
    attempts: 3, // 最大3回リトライ
    backoff: {
      type: 'exponential',
      delay: 2000,
    },
  },

  // 各ジョブタイプ別設定
  jobTypes: {
    'send-email': {
      attempts: 5,
      backoff: {
        type: 'exponential',
        delay: 1000,
      },
    },

    'monthly-rebate-calculation': {
      attempts: 1, // リトライなし(重要な計算)
      removeOnComplete: 100, // 履歴を多く保持
    },

    'inventory-sync': {
      attempts: 3,
      delay: 60000, // 1分遅延
      repeat: {
        pattern: '0 */6 * * *', // 6時間おき
      },
    },

    'image-processing': {
      attempts: 2,
      backoff: {
        type: 'fixed',
        delay: 5000,
      },
    },
  },
};

キャッシュ戦略設定

Redis キャッシュ戦略実装

// redis-cache-strategy.ts
import { CacheStrategy, Logger } from '@vendure/core';
import { Redis } from 'ioredis';

export class RedisCacheStrategy implements CacheStrategy {
  private readonly logger = new Logger(RedisCacheStrategy.name);
  private redis: Redis;

  constructor(
    private config: {
      redisOptions: RedisConfig;
      defaultTtl?: number;
    },
  ) {
    this.redis = createRedisConnection({
      ...config.redisOptions,
      keyPrefix: 'cache:',
    });
  }

  async get<T = any>(key: string): Promise<T | undefined> {
    try {
      const data = await this.redis.get(key);
      return data ? JSON.parse(data) : undefined;
    } catch (error) {
      this.logger.error(`Cache get failed for key ${key}:`, error);
      return undefined;
    }
  }

  async set<T = any>(key: string, value: T, ttl?: number): Promise<void> {
    try {
      const data = JSON.stringify(value);
      const expiry = ttl || this.config.defaultTtl || 3600; // 1時間デフォルト

      await this.redis.setex(key, expiry, data);
    } catch (error) {
      this.logger.error(`Cache set failed for key ${key}:`, error);
    }
  }

  async delete(key: string): Promise<void> {
    try {
      await this.redis.del(key);
    } catch (error) {
      this.logger.error(`Cache delete failed for key ${key}:`, error);
    }
  }

  async invalidateMany(pattern: string): Promise<void> {
    try {
      const keys = await this.redis.keys(pattern);
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    } catch (error) {
      this.logger.error(
        `Cache invalidation failed for pattern ${pattern}:`,
        error,
      );
    }
  }

  async has(key: string): Promise<boolean> {
    try {
      const exists = await this.redis.exists(key);
      return exists === 1;
    } catch (error) {
      this.logger.error(`Cache exists check failed for key ${key}:`, error);
      return false;
    }
  }
}

キャッシュ使用例

// product-cache-service.ts
export class ProductCacheService {
  constructor(private cacheStrategy: RedisCacheStrategy) {}

  async getProductDetails(productId: string) {
    const cacheKey = `product:details:${productId}`;
    const cached = await this.cacheStrategy.get(cacheKey);

    if (cached) {
      return cached;
    }

    // データベースから取得
    const product = await this.productService.findOne(productId);

    // 1時間キャッシュ
    await this.cacheStrategy.set(cacheKey, product, 3600);

    return product;
  }

  async invalidateProductCache(productId: string) {
    await this.cacheStrategy.invalidateMany(`product:*:${productId}`);
  }
}

レート制限設定

Redis ベースレート制限

// redis-rate-limiter.ts
export class RedisRateLimiter {
  constructor(private redis: Redis) {}

  async checkLimit(
    key: string,
    limit: number,
    windowMs: number,
  ): Promise<{ allowed: boolean; remaining: number; resetTime: Date }> {
    const window = Math.floor(Date.now() / windowMs);
    const redisKey = `rate_limit:${key}:${window}`;

    try {
      const current = await this.redis.incr(redisKey);

      if (current === 1) {
        // 新しいウィンドウの場合、TTLを設定
        await this.redis.expire(redisKey, Math.ceil(windowMs / 1000));
      }

      const remaining = Math.max(0, limit - current);
      const resetTime = new Date((window + 1) * windowMs);

      return {
        allowed: current <= limit,
        remaining,
        resetTime,
      };
    } catch (error) {
      // Redis エラーの場合は制限を無効化
      return {
        allowed: true,
        remaining: limit,
        resetTime: new Date(Date.now() + windowMs),
      };
    }
  }
}

// 使用例
export const apiRateLimiter = new RedisRateLimiter(redisConnection);

// GraphQL API レート制限ミドルウェア
export const rateLimitMiddleware = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  const clientId = req.ip || 'unknown';
  const result = await apiRateLimiter.checkLimit(
    `api:${clientId}`,
    100, // 1分間に100リクエスト
    60 * 1000, // 1分間
  );

  if (!result.allowed) {
    return res.status(429).json({
      error: 'Rate limit exceeded',
      resetTime: result.resetTime,
    });
  }

  next();
};

Vendure 設定統合

完全な Vendure 設定

// vendure-config.ts
import { VendureConfig } from '@vendure/core';
import { BullMQJobQueuePlugin } from '@vendure/job-queue-plugin/package/bullmq';

export const config: VendureConfig = {
  // Redis セッション管理
  authOptions: {
    sessionCacheStrategy: new RedisSessionCacheStrategy({
      redisOptions: getRedisConfig(),
      ttl: 7 * 24 * 60 * 60, // 7日間
    }),
  },

  // Redis キャッシュ戦略
  systemOptions: {
    cacheStrategy: new RedisCacheStrategy({
      redisOptions: getRedisConfig(),
      defaultTtl: 3600, // 1時間
    }),
  },

  // BullMQ ジョブキュー
  plugins: [
    BullMQJobQueuePlugin.init({
      connection: createBullMQConfig(),
      ...jobQueueConfig,
    }),
  ],
};

監視とメトリクス

Redis 監視設定

// redis-metrics.ts
export class RedisMetrics {
  constructor(private redis: Redis) {}

  async getConnectionStats() {
    const info = await this.redis.info('server');
    const stats = await this.redis.info('stats');

    return {
      uptime: this.parseInfo(info, 'uptime_in_seconds'),
      connectedClients: this.parseInfo(stats, 'connected_clients'),
      usedMemory: this.parseInfo(info, 'used_memory'),
      keyspace: await this.getKeyspaceInfo(),
    };
  }

  async getKeyspaceInfo() {
    const keyspace = await this.redis.info('keyspace');
    return {
      totalKeys: this.parseInfo(keyspace, 'keys'),
      expires: this.parseInfo(keyspace, 'expires'),
    };
  }

  private parseInfo(info: string, key: string): number {
    const match = info.match(new RegExp(`${key}:(\\d+)`));
    return match ? parseInt(match[1], 10) : 0;
  }
}

// ヘルスチェック
export const redisHealthCheck = async (redis: Redis): Promise<boolean> => {
  try {
    const result = await redis.ping();
    return result === 'PONG';
  } catch {
    return false;
  }
};

トラブルシューティング

一般的な問題と解決方法

1. 接続タイムアウト

// 接続設定の調整
const redisConfig = {
  connectTimeout: 10000, // 10秒
  commandTimeout: 5000, // 5秒
  retryDelayOnFailover: 1000,
  maxRetriesPerRequest: 3,
};

2. BullMQ ジョブ失敗

# Redis eviction policy を確認
redis-cli CONFIG GET maxmemory-policy
# 結果: noeviction である必要がある

# Upstash コンソールで確認・設定

3. メモリ使用量の最適化

// TTL の適切な設定
const cacheConfig = {
  shortTerm: 300, // 5分(商品価格など)
  mediumTerm: 3600, // 1時間(商品詳細など)
  longTerm: 86400, // 24時間(カテゴリなど)
};

// 不要なキーの削除
await redis.del('temporary:*');

4. パフォーマンス最適化

// Pipeline の使用
const pipeline = redis.pipeline();
pipeline.set('key1', 'value1');
pipeline.set('key2', 'value2');
pipeline.set('key3', 'value3');
await pipeline.exec();

// Lua スクリプトの活用
const script = `
  local keys = redis.call('keys', ARGV[1])
  for i=1,#keys do
    redis.call('del', keys[i])
  end
  return #keys
`;
const deletedCount = await redis.eval(script, 0, 'cache:product:*');

文書バージョン: 1.0 作成日: 2025年9月17日 次回レビュー: 2025年12月17日