🎄 科学と神々株式会社 アドベントカレンダー 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
🌟 まとめ
スケーラビリティの要点:
-
データベーススケーリング
- PostgreSQL移行
- パーティショニング
- コネクションプーリング
-
キャッシング
- Redis統合
- レート制限高速化
- ライセンス検証キャッシュ
-
ロードバランシング
- Nginx設定
- 複数サーバークラスタ
- ヘルスチェック
-
Docker化
- マルチステージビルド
- Docker Compose構成
- 水平スケーリング対応
前回: Day 21: テストシナリオと検証方法
次回: Day 23: 追加機能の実装アイデア
Happy Learning! 🎉