0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

License System Day 22: 商用化への道 - スケーラビリティ

Last updated at Posted at 2025-12-21

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 22: 商用化への道 - スケーラビリティ


📖 今日のテーマ

今日は、商用化に向けたスケーラビリティ戦略を学びます。

SQLiteからPostgreSQLへの移行、Redisキャッシング、ロードバランシング、水平スケーリング、Docker化、そしてモニタリングまで、本番環境で求められる拡張性を解説します。


🎯 スケーラビリティの目標

スケーラビリティ要件

パフォーマンス目標:
✅ 1,000 req/sec以上のスループット
✅ 99パーセンタイルレスポンスタイム < 100ms
✅ 99.9%以上の可用性 (SLA)
✅ 水平スケーリング対応(サーバー追加で性能向上)

ユーザー規模:
✅ 100万ユーザー対応
✅ 同時接続 10,000+
✅ 月間リクエスト 10億+

🗄️ データベーススケーリング

SQLiteからPostgreSQLへの移行

移行の理由

SQLiteの限界:
❌ 単一ファイル、並行書き込み制限
❌ 大規模データでのパフォーマンス低下
❌ レプリケーション機能なし

PostgreSQLの利点:
✅ 高い並行性(MVCC)
✅ レプリケーション・シャーディング対応
✅ 高度なインデックス(GIN, GiST, BRIN)
✅ 水平スケーリング可能

PostgreSQLスキーマ定義

-- schema_postgresql.sql
-- PostgreSQL専用スキーマ(パーティショニング対応)

-- ユーザーテーブル
CREATE TABLE users (
    user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- メールインデックス(高速検索)
CREATE INDEX idx_users_email ON users (email);

-- サブスクリプションテーブル(パーティショニング)
CREATE TABLE subscriptions (
    subscription_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    plan_type VARCHAR(50) NOT NULL,
    status VARCHAR(20) NOT NULL,
    start_date TIMESTAMP WITH TIME ZONE NOT NULL,
    end_date TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
) PARTITION BY RANGE (start_date);

-- 年ごとのパーティション(効率的なクエリ)
CREATE TABLE subscriptions_2025 PARTITION OF subscriptions
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

CREATE TABLE subscriptions_2026 PARTITION OF subscriptions
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

-- インデックス
CREATE INDEX idx_subscriptions_user_id ON subscriptions (user_id);
CREATE INDEX idx_subscriptions_status ON subscriptions (status) WHERE status = 'active';

-- ライセンステーブル
CREATE TABLE licenses (
    license_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    subscription_id UUID NOT NULL REFERENCES subscriptions(subscription_id),
    client_id VARCHAR(255) NOT NULL,
    activation_key TEXT NOT NULL,
    activated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    UNIQUE (user_id, client_id)
);

CREATE INDEX idx_licenses_user_id ON licenses (user_id);
CREATE INDEX idx_licenses_activation_key ON licenses USING HASH (activation_key);

-- レート制限テーブル(時系列データ)
CREATE TABLE rate_limits (
    id BIGSERIAL PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
    endpoint VARCHAR(255) NOT NULL,
    request_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    ip_address INET
) PARTITION BY RANGE (request_time);

-- 日ごとのパーティション(自動削除可能)
CREATE TABLE rate_limits_today PARTITION OF rate_limits
    FOR VALUES FROM (CURRENT_DATE) TO (CURRENT_DATE + INTERVAL '1 day');

-- 複合インデックス
CREATE INDEX idx_rate_limits_user_endpoint ON rate_limits (user_id, endpoint, request_time DESC);

NimでのPostgreSQL接続

# server/src/database_postgresql.nim
import std/[db_postgres, options, times, strutils]
import ../shared/types

type
  PostgresDatabase* = ref object
    db: DbConn
    host: string
    port: int
    user: string
    password: string
    database: string

proc newPostgresDatabase*(
    host: string = "localhost",
    port: int = 5432,
    user: string = "postgres",
    password: string = "",
    database: string = "license_system"
): PostgresDatabase =
  ## PostgreSQLデータベース接続

  result = PostgresDatabase(
    host: host,
    port: port,
    user: user,
    password: password,
    database: database
  )

  # 接続
  result.db = open(host, user, password, database)

  echo "✅ Connected to PostgreSQL: ", database

proc createUser*(self: PostgresDatabase, email, passwordHash: string): string =
  ## ユーザー作成(UUIDを返す)

  let row = self.db.getRow(sql"""
    INSERT INTO users (email, password_hash)
    VALUES ($1, $2)
    RETURNING user_id
  """, email, passwordHash)

  return row[0]

proc getUserByEmail*(self: PostgresDatabase, email: string): Option[User] =
  ## メールアドレスでユーザー取得

  let row = self.db.getRow(sql"""
    SELECT user_id, email, password_hash, created_at
    FROM users
    WHERE email = $1
  """, email)

  if row[0] == "":
    return none(User)

  return some(User(
    userId: row[0],
    email: row[1],
    passwordHash: row[2],
    createdAt: parse(row[3], "yyyy-MM-dd HH:mm:ss")
  ))

proc checkRateLimitPostgres*(
    self: PostgresDatabase,
    userId, endpoint: string,
    limit: int,
    windowSec: int = 3600
): bool =
  ## レート制限チェック(PostgreSQL + ウィンドウクエリ)

  let windowStart = now() - initDuration(seconds = windowSec)

  let count = self.db.getValue(sql"""
    SELECT COUNT(*)
    FROM rate_limits
    WHERE user_id = $1::uuid
      AND endpoint = $2
      AND request_time >= $3
  """, userId, endpoint, $windowStart).parseInt()

  return count < limit

proc recordRequestPostgres*(
    self: PostgresDatabase,
    userId, endpoint, ipAddress: string
) =
  ## リクエスト記録

  self.db.exec(sql"""
    INSERT INTO rate_limits (user_id, endpoint, ip_address)
    VALUES ($1::uuid, $2, $3::inet)
  """, userId, endpoint, ipAddress)

# コネクションプーリング
var dbPool*: seq[PostgresDatabase] = @[]

proc initDatabasePool*(size: int = 10) =
  ## データベースコネクションプール初期化

  for i in 1..size:
    dbPool.add(newPostgresDatabase())

  echo "✅ Database connection pool initialized: ", size, " connections"

proc getConnection*(): PostgresDatabase =
  ## プールから接続取得(ラウンドロビン)
  # 注: 実際にはもっと高度なプーリングロジック(アイドル検出など)が必要

  static var poolIndex = 0
  let conn = dbPool[poolIndex mod dbPool.len]
  poolIndex.inc
  return conn

🔥 Redisキャッシング戦略

Redisの活用パターン

キャッシング戦略:
1. セッションストア
   → JWTトークン検証結果
   → ユーザー情報

2. レート制限カウンター
   → 高速なインクリメント
   → TTL(有効期限)自動削除

3. ライセンスキャッシュ
   → アクティブなライセンス情報
   → DB負荷軽減

Nim + Redis実装

# server/src/redis_cache.nim
import std/[asyncdispatch, json, times, options]
import redis

type
  RedisCache* = ref object
    client: AsyncRedis
    host: string
    port: int

proc newRedisCache*(host: string = "localhost", port: int = 6379): Future[RedisCache] {.async.} =
  ## Redis接続

  result = RedisCache(host: host, port: port)
  result.client = await openAsync(host, Port(port))

  echo "✅ Connected to Redis: ", host, ":", port

proc cacheUserInfo*(self: RedisCache, userId: string, userInfo: JsonNode, ttl: int = 3600) {.async.} =
  ## ユーザー情報をキャッシュ(1時間TTL)

  let key = "user:" & userId
  discard await self.client.setex(key, ttl, $userInfo)

proc getUserInfoCached*(self: RedisCache, userId: string): Future[Option[JsonNode]] {.async.} =
  ## キャッシュからユーザー情報取得

  let key = "user:" & userId
  let value = await self.client.get(key)

  if value.isNone:
    return none(JsonNode)

  return some(parseJson(value.get))

proc incrementRateLimit*(
    self: RedisCache,
    userId, endpoint: string,
    windowSec: int = 3600
): Future[int] {.async.} =
  ## レート制限カウンターをインクリメント

  let key = "ratelimit:" & userId & ":" & endpoint
  let count = await self.client.incr(key)

  if count == 1:
    # 初回インクリメント時のみTTL設定
    discard await self.client.expire(key, windowSec)

  return count

proc getRateLimitCount*(
    self: RedisCache,
    userId, endpoint: string
): Future[int] {.async.} =
  ## 現在のレート制限カウント取得

  let key = "ratelimit:" & userId & ":" & endpoint
  let value = await self.client.get(key)

  if value.isNone:
    return 0

  return parseInt(value.get)

proc cacheLicenseValidation*(
    self: RedisCache,
    token: string,
    validationResult: JsonNode,
    ttl: int = 300
): Future[void] {.async.} =
  ## ライセンス検証結果をキャッシュ(5分TTL)

  let key = "license:validation:" & token
  discard await self.client.setex(key, ttl, $validationResult)

proc getLicenseValidationCached*(
    self: RedisCache,
    token: string
): Future[Option[JsonNode]] {.async.} =
  ## キャッシュからライセンス検証結果取得

  let key = "license:validation:" & token
  let value = await self.client.get(key)

  if value.isNone:
    return none(JsonNode)

  return some(parseJson(value.get))

キャッシュ統合パターン

# server/src/main.nim
import redis_cache, database_postgresql

var redisCache: RedisCache

proc validateLicenseWithCache(token: string): Future[JsonNode] {.async.} =
  ## ライセンス検証(キャッシュ優先)

  # 1. キャッシュ確認
  let cached = await redisCache.getLicenseValidationCached(token)
  if cached.isSome:
    echo "✅ Cache hit for license validation"
    return cached.get

  # 2. DBから検証(キャッシュミス)
  let db = getConnection()
  let claims = cryptoService.verifyJWT(token)

  if claims.isNone:
    return %*{"status": "invalid"}

  let userId = claims.get["user_id"].getStr()
  let subscription = db.getActiveSubscription(userId)

  let result = %*{
    "status": "valid",
    "premium": subscription.isSome,
    "plan_type": if subscription.isSome: subscription.get.planType else: "free"
  }

  # 3. 結果をキャッシュ
  await redisCache.cacheLicenseValidation(token, result, ttl = 300)

  return result

⚖️ ロードバランシング

Nginx設定(ラウンドロビン)

# /etc/nginx/nginx.conf
upstream license_system_backend {
    # ラウンドロビン(デフォルト)
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;
    server 127.0.0.1:8082;

    # ヘルスチェック(Nginx Plus)
    # health_check interval=10s fails=3 passes=2;

    # セッションスティッキネス(オプション)
    # ip_hash;
}

server {
    listen 80;
    server_name api.license-system.com;

    # リバースプロキシ設定
    location / {
        proxy_pass http://license_system_backend;

        # ヘッダー転送
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # タイムアウト
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;

        # バッファリング
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    # ヘルスチェックエンドポイント
    location /health {
        proxy_pass http://license_system_backend/health;
        access_log off;
    }

    # 静的ファイルキャッシュ
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|wasm)$ {
        proxy_pass http://license_system_backend;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

複数サーバーの起動スクリプト

#!/bin/bash
# start_cluster.sh - 複数サーバー起動

# サーバー1
PORT=8080 ./server/src/main &
echo "Server 1 started on port 8080"

# サーバー2
PORT=8081 ./server/src/main &
echo "Server 2 started on port 8081"

# サーバー3
PORT=8082 ./server/src/main &
echo "Server 3 started on port 8082"

# ヘルスチェック待機
sleep 5

# Nginx再起動
sudo systemctl restart nginx

echo "✅ License System cluster started"
echo "   Frontend: http://localhost (Nginx)"
echo "   Backend: http://localhost:8080, 8081, 8082"

🐳 Docker化

Dockerfile(マルチステージビルド)

# Dockerfile
# マルチステージビルド(サイズ最適化)

# ステージ1: ビルド
FROM nimlang/nim:2.0.0-alpine AS builder

WORKDIR /app

# 依存関係インストール
RUN apk add --no-cache \
    build-base \
    openssl-dev \
    sqlite-dev \
    postgresql-dev

# ソースコードコピー
COPY server/ ./server/
COPY shared/ ./shared/
COPY keys/ ./keys/

# Nimパッケージインストール
RUN nimble install -y jester nimcrypto jwt redis db_postgres

# ビルド(リリースモード)
RUN cd server && nim c -d:release -d:ssl src/main.nim

# ステージ2: 実行環境
FROM alpine:latest

WORKDIR /app

# ランタイム依存関係
RUN apk add --no-cache \
    openssl \
    libpq

# ビルド成果物コピー
COPY --from=builder /app/server/src/main ./server
COPY --from=builder /app/keys ./keys
COPY --from=builder /app/server/schema.sql ./

# 非rootユーザー作成
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN chown -R appuser:appgroup /app
USER appuser

# ポート公開
EXPOSE 8080

# ヘルスチェック
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1

# 起動コマンド
CMD ["./server"]

Docker Compose(マイクロサービス構成)

# docker-compose.yml
version: '3.8'

services:
  # PostgreSQLデータベース
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: license_system
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: your_secure_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./server/schema_postgresql.sql:/docker-entrypoint-initdb.d/schema.sql
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Redisキャッシュ
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

  # ライセンスシステムサーバー(3インスタンス)
  license-server-1:
    build: .
    environment:
      SERVER_PORT: 8080
      DATABASE_HOST: postgres
      DATABASE_PORT: 5432
      DATABASE_USER: postgres
      DATABASE_PASSWORD: your_secure_password
      DATABASE_NAME: license_system
      REDIS_HOST: redis
      REDIS_PORT: 6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "8080:8080"

  license-server-2:
    build: .
    environment:
      SERVER_PORT: 8080
      DATABASE_HOST: postgres
      REDIS_HOST: redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "8081:8080"

  license-server-3:
    build: .
    environment:
      SERVER_PORT: 8080
      DATABASE_HOST: postgres
      REDIS_HOST: redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "8082:8080"

  # Nginxロードバランサー
  nginx:
    image: nginx:alpine
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - license-server-1
      - license-server-2
      - license-server-3

volumes:
  postgres_data:

Docker起動コマンド

# ビルドと起動
docker-compose up -d --build

# ログ確認
docker-compose logs -f

# スケーリング(サーバーを5台に増やす)
docker-compose up -d --scale license-server=5

# 停止
docker-compose down

# 完全削除(データも含む)
docker-compose down -v

🌟 まとめ

スケーラビリティの要点:

  1. データベーススケーリング

    • PostgreSQL移行
    • パーティショニング
    • コネクションプーリング
  2. キャッシング

    • Redis統合
    • レート制限高速化
    • ライセンス検証キャッシュ
  3. ロードバランシング

    • Nginx設定
    • 複数サーバークラスタ
    • ヘルスチェック
  4. Docker化

    • マルチステージビルド
    • Docker Compose構成
    • 水平スケーリング対応

前回: Day 21: テストシナリオと検証方法
次回: Day 23: 追加機能の実装アイデア

Happy Learning! 🎉

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?