第6章:デプロイとセキュリティ

はじめに

ft_transcendenceは、Inceptionで学んだDocker知識を活かして、本番環境にデプロイします。本章では、Docker化、HTTPS設定、セキュリティベストプラクティス、テスト戦略を学びます。

---

1. Docker構成

1.1 完全版docker-compose.yml

version: "3.8"

services:
  # Nginx リバースプロキシ
  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    container_name: nginx
    ports:
      - "443:443"
    depends_on:
      - frontend
      - backend
    volumes:
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    networks:
      - transcendence
    restart: always
    healthcheck:
      test: ["CMD", "curl", "-f", "https://localhost/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # フロントエンド
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        - VITE_API_URL=/api
        - VITE_WS_URL=/socket.io
    container_name: frontend
    expose:
      - "80"
    networks:
      - transcendence
    restart: always

  # バックエンド API
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: backend
    expose:
      - "3000"
      - "3001"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
      - REDIS_URL=redis://redis:6379
      - INTRA_CLIENT_ID=${INTRA_CLIENT_ID}
      - INTRA_CLIENT_SECRET=${INTRA_CLIENT_SECRET}
      - INTRA_CALLBACK_URL=https://${DOMAIN_NAME}/api/auth/callback
      - JWT_SECRET=${JWT_SECRET}
      - JWT_EXPIRATION=1d
      - FRONTEND_URL=https://${DOMAIN_NAME}
      - TWO_FACTOR_APP_NAME=ft_transcendence
    networks:
      - transcendence
    restart: always
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # PostgreSQL データベース
  db:
    image: postgres:15-alpine
    container_name: db
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    environment:
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASS}
      - POSTGRES_DB=${DB_NAME}
    networks:
      - transcendence
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Redis (セッション、キャッシュ)
  redis:
    image: redis:7-alpine
    container_name: redis
    volumes:
      - redis_data:/data
    networks:
      - transcendence
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:
    driver: local
  redis_data:
    driver: local

networks:
  transcendence:
    driver: bridge
    name: transcendence

1.2 バックエンドDockerfile

# backend/Dockerfile

# ビルドステージ
FROM node:18-alpine AS builder

WORKDIR /app

# 依存関係のインストール
COPY package*.json ./
RUN npm ci

# ソースコードのコピーとビルド
COPY . .
RUN npm run build

# 本番ステージ
FROM node:18-alpine AS production

WORKDIR /app

# セキュリティアップデート
RUN apk update && apk upgrade && \
    apk add --no-cache curl && \
    rm -rf /var/cache/apk/*

# 本番依存関係のみインストール
COPY package*.json ./
RUN npm ci --only=production && \
    npm cache clean --force

# ビルド成果物をコピー
COPY --from=builder /app/dist ./dist

# 非rootユーザーの作成
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nestjs -u 1001 -G nodejs

# 権限設定
RUN chown -R nestjs:nodejs /app

USER nestjs

EXPOSE 3000 3001

CMD ["node", "dist/main.js"]

1.3 フロントエンドDockerfile

# frontend/Dockerfile

# ビルドステージ
FROM node:18-alpine AS builder

WORKDIR /app

ARG VITE_API_URL
ARG VITE_WS_URL

# 依存関係のインストール
COPY package*.json ./
RUN npm ci

# ソースコードのコピーとビルド
COPY . .
RUN npm run build

# 本番ステージ
FROM nginx:alpine

# セキュリティアップデート
RUN apk update && apk upgrade && \
    rm -rf /var/cache/apk/*

# ビルド成果物をコピー
COPY --from=builder /app/dist /usr/share/nginx/html

# Nginx設定
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 非rootユーザー設定
RUN chown -R nginx:nginx /usr/share/nginx/html && \
    chown -R nginx:nginx /var/cache/nginx && \
    chown -R nginx:nginx /var/log/nginx && \
    touch /var/run/nginx.pid && \
    chown -R nginx:nginx /var/run/nginx.pid

USER nginx

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

1.4 Nginx設定

# nginx/nginx.conf

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # ログ設定
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;
    error_log /var/log/nginx/error.log warn;

    # パフォーマンス設定
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    # Gzip圧縮
    gzip on;
    gzip_types text/plain text/css application/json application/javascript
               text/xml application/xml text/javascript;
    gzip_min_length 1000;

    # アップストリーム
    upstream backend_api {
        server backend:3000;
        keepalive 32;
    }

    upstream backend_ws {
        server backend:3001;
    }

    upstream frontend_app {
        server frontend:80;
    }

    # レートリミット
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;

    # HTTPS サーバー
    server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name _;

        # SSL設定
        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_prefer_server_ciphers on;
        ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
        ssl_session_timeout 1d;
        ssl_session_cache shared:SSL:50m;
        ssl_stapling off;

        # セキュリティヘッダー
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' wss:; font-src 'self';" always;

        # ヘルスチェック
        location /health {
            access_log off;
            return 200 "OK\n";
            add_header Content-Type text/plain;
        }

        # API エンドポイント
        location /api/ {
            limit_req zone=api burst=20 nodelay;

            proxy_pass http://backend_api/;
            proxy_http_version 1.1;
            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_set_header Connection "";
            proxy_buffering off;

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

        # 認証エンドポイント(より厳しいレート制限)
        location /api/auth/ {
            limit_req zone=auth burst=5 nodelay;

            proxy_pass http://backend_api/auth/;
            proxy_http_version 1.1;
            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;
        }

        # WebSocket
        location /socket.io/ {
            proxy_pass http://backend_ws/socket.io/;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            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;

            # WebSocket タイムアウト
            proxy_connect_timeout 7d;
            proxy_send_timeout 7d;
            proxy_read_timeout 7d;
        }

        # 静的ファイル (SPA)
        location / {
            proxy_pass http://frontend_app/;
            proxy_http_version 1.1;
            proxy_set_header Host $host;

            # キャッシュ設定
            proxy_cache_valid 200 1h;
        }
    }
}

---

2. セキュリティ

2.1 環境変数管理

# .env.example
# データベース
DB_USER=transcendence
DB_PASS=
DB_NAME=transcendence

# 42 OAuth
INTRA_CLIENT_ID=
INTRA_CLIENT_SECRET=

# JWT
JWT_SECRET=

# ドメイン
DOMAIN_NAME=localhost

# setup_env.sh - 初期セットアップスクリプト
#!/bin/bash

if [ ! -f .env ]; then
    cp .env.example .env

    # ランダムなパスワード/シークレット生成
    DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)
    JWT_SECRET=$(openssl rand -base64 64 | tr -dc 'a-zA-Z0-9' | head -c 64)

    sed -i "s/^DB_PASS=.*/DB_PASS=${DB_PASS}/" .env
    sed -i "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env

    chmod 600 .env

    echo "Environment file created. Please fill in:"
    echo "  - INTRA_CLIENT_ID"
    echo "  - INTRA_CLIENT_SECRET"
    echo "  - DOMAIN_NAME"
fi

2.2 入力バリデーション

// backend/src/common/dto/validation.ts
import {
    IsString,
    IsEmail,
    Length,
    Matches,
    IsNotEmpty,
    MaxLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
import * as sanitizeHtml from 'sanitize-html';

// HTMLサニタイズトランスフォーマー
export const SanitizeHtml = () =>
    Transform(({ value }) =>
        typeof value === 'string'
            ? sanitizeHtml(value, { allowedTags: [], allowedAttributes: {} })
            : value
    );

// ユーザー名バリデーション
export class UsernameDto {
    @IsString()
    @Length(3, 20)
    @Matches(/^[a-zA-Z0-9_-]+$/, {
        message: 'Username can only contain letters, numbers, underscores, and hyphens',
    })
    @SanitizeHtml()
    username: string;
}

// メッセージバリデーション
export class CreateMessageDto {
    @IsNotEmpty()
    @IsString()
    @MaxLength(1000)
    @SanitizeHtml()
    content: string;
}

// チャンネル作成バリデーション
export class CreateChannelDto {
    @IsString()
    @Length(1, 50)
    @Matches(/^[a-zA-Z0-9_\- ]+$/)
    @SanitizeHtml()
    name: string;

    @IsString()
    type: 'public' | 'private' | 'protected';

    @IsString()
    @Length(4, 50)
    password?: string;
}

2.3 CSRF保護

// backend/src/common/middleware/csrf.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as csurf from 'csurf';

@Injectable()
export class CsrfMiddleware implements NestMiddleware {
    private csrfProtection = csurf({
        cookie: {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'lax',
        },
    });

    use(req: Request, res: Response, next: NextFunction) {
        // WebSocket と OAuth コールバックはCSRFをスキップ
        if (
            req.path.startsWith('/socket.io') ||
            req.path === '/auth/callback'
        ) {
            return next();
        }

        this.csrfProtection(req, res, next);
    }
}

// フロントエンドでのCSRFトークン取得
export async function getCsrfToken(): Promise<string> {
    const response = await fetch('/api/csrf-token', {
        credentials: 'include',
    });
    const data = await response.json();
    return data.csrfToken;
}

// リクエストにCSRFトークンを含める
async function fetchWithCsrf(url: string, options: RequestInit = {}) {
    const csrfToken = await getCsrfToken();

    return fetch(url, {
        ...options,
        headers: {
            ...options.headers,
            'X-CSRF-Token': csrfToken,
        },
        credentials: 'include',
    });
}

2.4 レート制限

// backend/src/common/guards/throttle.guard.ts
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
    protected getTracker(req: Record<string, any>): string {
        // IPアドレスとユーザーIDの組み合わせでトラッキング
        const ip = req.ip || req.connection?.remoteAddress;
        const userId = req.user?.id || 'anonymous';
        return `${ip}-${userId}`;
    }
}

// app.module.ts での設定
@Module({
    imports: [
        ThrottlerModule.forRoot({
            ttl: 60,      // 60秒
            limit: 100,   // 100リクエスト
        }),
    ],
})
export class AppModule {}

// エンドポイント別のレート制限
@Controller('auth')
export class AuthController {
    @Post('login')
    @Throttle(5, 60) // 60秒間に5回まで
    login() {}

    @Post('2fa/verify')
    @Throttle(3, 60) // 60秒間に3回まで(ブルートフォース対策)
    verify2FA() {}
}

2.5 SQLインジェクション対策

// TypeORMを使用したパラメータ化クエリ

// 良い例: パラメータ化クエリ
async findByUsername(username: string): Promise<User | null> {
    return this.usersRepository.findOne({
        where: { username },
    });
}

// 良い例: QueryBuilder でのパラメータバインディング
async searchUsers(query: string): Promise<User[]> {
    return this.usersRepository
        .createQueryBuilder('user')
        .where('user.username LIKE :query', { query: `%${query}%` })
        .getMany();
}

// 悪い例(使用禁止): 文字列補間
// const users = await this.usersRepository.query(
//     `SELECT * FROM users WHERE username = '${username}'`
// );

2.6 XSS対策

// フロントエンドでのエスケープ
function escapeHtml(text: string): string {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

// メッセージ表示時
function renderMessage(message: Message): string {
    return `
        <div class="message">
            <strong>${escapeHtml(message.sender.username)}</strong>
            <p>${escapeHtml(message.content)}</p>
        </div>
    `;
}

// バックエンドでのサニタイズ
import * as sanitizeHtml from 'sanitize-html';

function sanitizeInput(input: string): string {
    return sanitizeHtml(input, {
        allowedTags: [],
        allowedAttributes: {},
    });
}

---

3. テスト

3.1 ユニットテスト

// backend/src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { Repository } from 'typeorm';

describe('UsersService', () => {
    let service: UsersService;
    let repository: jest.Mocked<Repository<User>>;

    const mockUser: User = {
        id: 1,
        username: 'testuser',
        email: 'test@example.com',
        status: 'online',
        wins: 0,
        losses: 0,
        ladderLevel: 1,
    } as User;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            providers: [
                UsersService,
                {
                    provide: getRepositoryToken(User),
                    useValue: {
                        find: jest.fn(),
                        findOne: jest.fn(),
                        create: jest.fn(),
                        save: jest.fn(),
                        update: jest.fn(),
                    },
                },
            ],
        }).compile();

        service = module.get<UsersService>(UsersService);
        repository = module.get(getRepositoryToken(User));
    });

    it('should be defined', () => {
        expect(service).toBeDefined();
    });

    describe('findById', () => {
        it('should return a user', async () => {
            repository.findOne.mockResolvedValue(mockUser);

            const result = await service.findById(1);

            expect(result).toEqual(mockUser);
            expect(repository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
        });

        it('should throw NotFoundException if user not found', async () => {
            repository.findOne.mockResolvedValue(null);

            await expect(service.findById(999)).rejects.toThrow();
        });
    });

    describe('create', () => {
        it('should create a new user', async () => {
            const createDto = {
                username: 'newuser',
                email: 'new@example.com',
            };

            repository.findOne.mockResolvedValue(null);
            repository.create.mockReturnValue(mockUser);
            repository.save.mockResolvedValue(mockUser);

            const result = await service.create(createDto);

            expect(result).toEqual(mockUser);
            expect(repository.create).toHaveBeenCalledWith(createDto);
        });
    });
});

3.2 E2Eテスト

// backend/test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('App E2E', () => {
    let app: INestApplication;
    let authCookie: string;

    beforeAll(async () => {
        const moduleFixture: TestingModule = await Test.createTestingModule({
            imports: [AppModule],
        }).compile();

        app = moduleFixture.createNestApplication();
        app.useGlobalPipes(new ValidationPipe());
        await app.init();
    });

    afterAll(async () => {
        await app.close();
    });

    describe('Authentication', () => {
        it('should redirect to 42 OAuth', () => {
            return request(app.getHttpServer())
                .get('/auth/login/42')
                .expect(302)
                .expect('Location', /api\.intra\.42\.fr/);
        });
    });

    describe('Users', () => {
        it('should return 401 without auth', () => {
            return request(app.getHttpServer())
                .get('/users/me')
                .expect(401);
        });
    });

    describe('Chat', () => {
        it('should return channels', () => {
            return request(app.getHttpServer())
                .get('/channels')
                .set('Cookie', authCookie)
                .expect(200)
                .expect((res) => {
                    expect(Array.isArray(res.body)).toBe(true);
                });
        });
    });
});

3.3 フロントエンドテスト

// frontend/src/__tests__/components/UserCard.test.ts
import { UserCard } from '../../components/UserCard';

describe('UserCard', () => {
    it('should render user information', () => {
        const user = {
            id: 1,
            username: 'testuser',
            avatarUrl: '/avatar.png',
            status: 'online',
            wins: 10,
            losses: 5,
        };

        const card = new UserCard({ user });
        const element = card.render();

        expect(element.querySelector('.username')?.textContent).toBe('testuser');
        expect(element.querySelector('.status')?.classList.contains('online')).toBe(true);
    });
});

---

4. CI/CD

4.1 GitHub Actions

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
          cache-dependency-path: backend/package-lock.json

      - name: Install Backend Dependencies
        working-directory: ./backend
        run: npm ci

      - name: Run Linter
        working-directory: ./backend
        run: npm run lint

      - name: Run Tests
        working-directory: ./backend
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test
          JWT_SECRET: test-secret
        run: npm run test:cov

      - name: Upload Coverage
        uses: codecov/codecov-action@v3
        with:
          directory: ./backend/coverage

  build:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Build Docker Images
        run: docker-compose build

      - name: Security Scan
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'fs'
          scan-ref: '.'
          severity: 'CRITICAL,HIGH'

---

5. Makefile

# Makefile

NAME = transcendence

# 環境変数
include .env
export

# 色
GREEN = \033[0;32m
YELLOW = \033[0;33m
RED = \033[0;31m
NC = \033[0m

# デフォルトターゲット
all: setup ssl build up

# 初期セットアップ
setup:
	@echo "$(GREEN)Setting up environment...$(NC)"
	@chmod +x scripts/*.sh
	@./scripts/setup_env.sh

# SSL証明書生成
ssl:
	@echo "$(GREEN)Generating SSL certificates...$(NC)"
	@mkdir -p nginx/ssl
	@openssl req -x509 -nodes -days 365 \
		-newkey rsa:2048 \
		-keyout nginx/ssl/key.pem \
		-out nginx/ssl/cert.pem \
		-subj "/C=JP/ST=Tokyo/L=Tokyo/O=42/CN=$(DOMAIN_NAME)"

# ビルド
build:
	@echo "$(GREEN)Building Docker images...$(NC)"
	@docker-compose build

# 起動
up:
	@echo "$(GREEN)Starting containers...$(NC)"
	@docker-compose up -d

# 停止
down:
	@echo "$(YELLOW)Stopping containers...$(NC)"
	@docker-compose down

# ログ
logs:
	@docker-compose logs -f

logs-backend:
	@docker-compose logs -f backend

logs-nginx:
	@docker-compose logs -f nginx

# シェルアクセス
shell-backend:
	@docker-compose exec backend sh

shell-db:
	@docker-compose exec db psql -U $(DB_USER) -d $(DB_NAME)

shell-redis:
	@docker-compose exec redis redis-cli

# テスト
test:
	@echo "$(GREEN)Running tests...$(NC)"
	@docker-compose exec backend npm run test

test-e2e:
	@docker-compose exec backend npm run test:e2e

test-cov:
	@docker-compose exec backend npm run test:cov

# リント
lint:
	@docker-compose exec backend npm run lint

# 状態確認
ps:
	@docker-compose ps

# ヘルスチェック
health:
	@echo "$(GREEN)Checking health...$(NC)"
	@curl -sf https://localhost/health > /dev/null && echo "$(GREEN)Nginx: OK$(NC)" || echo "$(RED)Nginx: FAIL$(NC)"
	@curl -sf http://localhost:3000/health > /dev/null && echo "$(GREEN)Backend: OK$(NC)" || echo "$(RED)Backend: FAIL$(NC)"
	@docker-compose exec db pg_isready -U $(DB_USER) > /dev/null && echo "$(GREEN)Database: OK$(NC)" || echo "$(RED)Database: FAIL$(NC)"
	@docker-compose exec redis redis-cli ping > /dev/null && echo "$(GREEN)Redis: OK$(NC)" || echo "$(RED)Redis: FAIL$(NC)"

# クリーンアップ
clean: down
	@echo "$(YELLOW)Cleaning up...$(NC)"
	@docker system prune -f

fclean: clean
	@echo "$(RED)Full cleanup...$(NC)"
	@docker-compose down -v
	@docker system prune -af
	@docker volume prune -f

# 再構築
re: fclean all

# バックアップ
backup:
	@echo "$(GREEN)Creating backup...$(NC)"
	@mkdir -p backups
	@docker-compose exec db pg_dump -U $(DB_USER) $(DB_NAME) > backups/db_$(shell date +%Y%m%d_%H%M%S).sql
	@echo "$(GREEN)Backup created$(NC)"

# リストア
restore:
	@echo "$(YELLOW)Restoring from latest backup...$(NC)"
	@docker-compose exec -T db psql -U $(DB_USER) $(DB_NAME) < $(shell ls -t backups/*.sql | head -1)

.PHONY: all setup ssl build up down logs logs-backend logs-nginx \
        shell-backend shell-db shell-redis test test-e2e test-cov lint \
        ps health clean fclean re backup restore

---

6. デプロイチェックリスト

6.1 本番前確認

セキュリティ:
□ .env ファイルが .gitignore に含まれている
□ 強力なパスワード/シークレットを使用
□ HTTPS が正しく設定されている
□ CORS が適切に設定されている
□ レート制限が有効
□ 入力バリデーションが実装されている
□ CSRFトークンが有効
□ セキュリティヘッダーが設定されている

Docker:
□ 非rootユーザーで実行
□ 不要なパッケージを削除
□ マルチステージビルドを使用
□ ヘルスチェックが設定されている
□ リソース制限が設定されている

データベース:
□ バックアップ戦略が決定されている
□ マイグレーションが適用されている
□ インデックスが適切に設定されている

テスト:
□ ユニットテストがパス
□ E2Eテストがパス
□ 負荷テストを実施

監視:
□ ログが適切に出力されている
□ エラー監視が設定されている

---

まとめ

本章で学んだこと:

  • Docker構成: マルチサービス構成
  • Nginx設定: リバースプロキシ、セキュリティ
  • セキュリティ: バリデーション、CSRF、レート制限
  • テスト: ユニット、E2E、カバレッジ
  • CI/CD: GitHub Actions
  • 運用: Makefile、バックアップ

これでft_transcendenceプロジェクトの主要な実装は完了です。次はAppendixで、モダンWeb開発の潮流を学びます。