LESSON 30分

ストーリー

佐藤CTO
アーキテクチャスタイルと通信パターンの次は”どこで動かすか”だ
佐藤CTO
コンテナかサーバーレスか。この選択はコスト、運用負荷、スケーリング特性に大きく影響する
あなた
サーバーレスなら管理が楽そうですが…
佐藤CTO
確かに運用は楽だ。だがコールドスタートの問題、実行時間の制限、ベンダーロックインなど考慮すべき点がある。どちらが優れているではなく、ワークロードの特性に合わせて選ぶんだ

コンテナアーキテクチャ

コンテナとは

コンテナは、アプリケーションとその依存関係を1つのパッケージに閉じ込めた実行単位です。どの環境でも同じように動作することを保証します。

graph TD
    subgraph Host["ホストOS"]
        subgraph CA["Container A"]
            AppA["App"]
            LibsA["Libs"]
            RtA["Runtime"]
            AppA --> LibsA --> RtA
        end
        subgraph CB["Container B"]
            AppB["App"]
            LibsB["Libs"]
            RtB["Runtime"]
            AppB --> LibsB --> RtB
        end
        CR["Container Runtime(Docker)"]
        RtA --> CR
        RtB --> CR
    end

    classDef appStyle fill:#4a90d9,stroke:#2c5f8a,color:#fff
    classDef libStyle fill:#67b7dc,stroke:#3a8ab5,color:#fff
    classDef rtStyle fill:#5cb85c,stroke:#3d8b3d,color:#fff
    classDef crStyle fill:#e8a838,stroke:#b07c1e,color:#fff

    class AppA,AppB appStyle
    class LibsA,LibsB libStyle
    class RtA,RtB rtStyle
    class CR crStyle

Dockerfileの例

# マルチステージビルドでイメージサイズを最小化
# Stage 1: ビルド
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: 実行
FROM node:20-alpine AS runner
WORKDIR /app

# セキュリティ: 非rootユーザーで実行
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 appuser

COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./

USER appuser
EXPOSE 3000

# ヘルスチェック
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

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

Docker Composeによるマルチサービス構成

# docker-compose.yml
version: '3.8'

services:
  # APIサービス
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: '0.5'
          memory: 512M

  # ワーカーサービス(イベント処理)
  worker:
    build:
      context: ./worker
      dockerfile: Dockerfile
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/myapp
      - QUEUE_URL=amqp://rabbitmq:5672
    depends_on:
      - rabbitmq

  # データベース
  db:
    image: postgres:16-alpine
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

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

  # メッセージブローカー
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "15672:15672"

volumes:
  db_data:

Kubernetes(本番環境)

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-service
  template:
    metadata:
      labels:
        app: api-service
    spec:
      containers:
        - name: api
          image: myapp/api:v1.2.0
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: url
---
# 水平オートスケーリング
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

サーバーレスアーキテクチャ

サーバーレスとは

サーバーレスは、サーバーの管理をクラウドプロバイダーに完全に委託し、関数単位でコードを実行するアーキテクチャです。リクエストがあった時だけ実行され、使った分だけ課金されます。

graph LR
    A["リクエスト"] --> B["API Gateway"]
    B --> C["Lambda Function"]
    C --> D["DynamoDB"]
    C --> E["実行後に自動的に終了
課金: 実行時間 x メモリ"] classDef reqStyle fill:#f3f4f6,stroke:#9ca3af,color:#374151 classDef gwStyle fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e classDef lambdaStyle fill:#d1fae5,stroke:#059669,color:#065f46 classDef dbStyle fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af classDef noteStyle fill:#f3e8ff,stroke:#7c3aed,color:#5b21b6 class A reqStyle class B gwStyle class C lambdaStyle class D dbStyle class E noteStyle

サーバーの管理は不要 / 0リクエスト時のコストは0

AWS Lambda + API Gatewayの例

// handler.ts - Lambda関数
import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';

const dynamodb = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME!;

// 注文作成
export const createOrder: APIGatewayProxyHandler = async (event) => {
  try {
    const body = JSON.parse(event.body ?? '{}');

    const orderId = crypto.randomUUID();
    const order = {
      id: orderId,
      customerId: body.customerId,
      items: body.items,
      totalAmount: body.totalAmount,
      status: 'CREATED',
      createdAt: new Date().toISOString(),
    };

    await dynamodb.send(new PutItemCommand({
      TableName: TABLE_NAME,
      Item: {
        PK: { S: `ORDER#${orderId}` },
        SK: { S: 'METADATA' },
        data: { S: JSON.stringify(order) },
        GSI1PK: { S: `CUSTOMER#${body.customerId}` },
        GSI1SK: { S: order.createdAt },
      },
    }));

    return response(201, { orderId, message: '注文を作成しました' });
  } catch (error) {
    console.error('注文作成エラー:', error);
    return response(500, { message: '内部エラーが発生しました' });
  }
};

// 注文取得
export const getOrder: APIGatewayProxyHandler = async (event) => {
  const orderId = event.pathParameters?.orderId;
  if (!orderId) return response(400, { message: 'orderIdが必要です' });

  const result = await dynamodb.send(new GetItemCommand({
    TableName: TABLE_NAME,
    Key: {
      PK: { S: `ORDER#${orderId}` },
      SK: { S: 'METADATA' },
    },
  }));

  if (!result.Item) return response(404, { message: '注文が見つかりません' });

  return response(200, JSON.parse(result.Item.data.S!));
};

function response(statusCode: number, body: unknown): APIGatewayProxyResult {
  return {
    statusCode,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  };
}

serverless.yml(Serverless Framework)

service: order-service

provider:
  name: aws
  runtime: nodejs20.x
  region: ap-northeast-1
  memorySize: 256
  timeout: 30
  environment:
    TABLE_NAME: !Ref OrdersTable
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:PutItem
            - dynamodb:GetItem
            - dynamodb:Query
          Resource:
            - !GetAtt OrdersTable.Arn
            - !Sub "${OrdersTable.Arn}/index/*"

functions:
  createOrder:
    handler: handler.createOrder
    events:
      - httpApi:
          path: /orders
          method: post

  getOrder:
    handler: handler.getOrder
    events:
      - httpApi:
          path: /orders/{orderId}
          method: get

  # イベント駆動: DynamoDB Streamsから在庫を更新
  processOrderStream:
    handler: handler.processOrderStream
    events:
      - stream:
          type: dynamodb
          arn: !GetAtt OrdersTable.StreamArn
          batchSize: 10
          startingPosition: TRIM_HORIZON

resources:
  Resources:
    OrdersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-orders
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: PK
            AttributeType: S
          - AttributeName: SK
            AttributeType: S
          - AttributeName: GSI1PK
            AttributeType: S
          - AttributeName: GSI1SK
            AttributeType: S
        KeySchema:
          - AttributeName: PK
            KeyType: HASH
          - AttributeName: SK
            KeyType: RANGE
        GlobalSecondaryIndexes:
          - IndexName: GSI1
            KeySchema:
              - AttributeName: GSI1PK
                KeyType: HASH
              - AttributeName: GSI1SK
                KeyType: RANGE
            Projection:
              ProjectionType: ALL
        StreamSpecification:
          StreamViewType: NEW_AND_OLD_IMAGES

コンテナ vs サーバーレス:詳細比較

コールドスタート

graph TD
    subgraph Container["コンテナ(常時起動): 合計 50ms"]
        CR["リクエスト"] --> CC["既に起動済みの
コンテナ
0ms"] --> CRes["レスポンス
50ms"] end subgraph Serverless["サーバーレス(コールドスタート): 合計 550ms"] SR["リクエスト"] --> SB["コンテナ起動
200ms"] --> SI["ランタイム初期化
300ms"] --> SE["関数実行
50ms"] --> SRes["レスポンス"] end classDef reqStyle fill:#f3f4f6,stroke:#9ca3af,color:#374151 classDef fastStyle fill:#d1fae5,stroke:#059669,color:#065f46 classDef slowStyle fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#92400e class CR,SR,CRes,SRes reqStyle class CC fastStyle class SB,SI,SE slowStyle

2回目以降はウォームスタートで50ms程度。軽減方法: Provisioned Concurrency / 定期的なウォームアップ / 軽量ランタイム使用

コストモデル

graph LR
    subgraph Container["コンテナ(ECS Fargate例)"]
        C1["vCPU: 0.5 / Memory: 1GB
$0.04048/時 x 24h x 30日
= $29.15/月
x 2インスタンス = $58.30/月

特徴: 固定費
リクエスト0でもコスト発生
"] end subgraph Serverless["サーバーレス(Lambda例)"] S1["Memory: 256MB / 平均200ms
月間100万リクエスト

リクエスト料金: $0.20
実行時間料金: $0.83
合計: 約$1.03/月

特徴: 従量課金
リクエスト0ならコスト0
"] end classDef containerStyle fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af classDef serverlessStyle fill:#d1fae5,stroke:#059669,color:#065f46 class C1 containerStyle class S1 serverlessStyle

リクエスト数が少ないほどサーバーレスが有利。リクエスト数が多い(数千万/月〜)とコンテナが有利になる

総合比較表

特性コンテナ(ECS/K8s)サーバーレス(Lambda)
コールドスタートなし(常時起動)あり(100ms〜数秒)
最大実行時間制限なし15分(Lambda)
スケーリング速度分単位ミリ秒〜秒単位
スケーリング上限ノード数に依存同時実行数(デフォルト1000)
コストモデル固定費+変動費完全従量課金
運用負荷中〜高(パッチ、監視、スケーリング設定)低(プロバイダーが管理)
デバッグ容易(コンテナにアタッチ可能)困難(ローカル再現が難しい)
ステートフル処理可能原則ステートレス
ベンダーロックイン低い(OCI標準)高い(プロバイダー固有)
ローカル開発容易(Docker Compose)ツールが必要(SAM, Serverless)
WebSocket/長時間接続可能制限あり
GPU利用可能限定的

ハイブリッドアプローチ

実際のプロジェクトでは、コンテナとサーバーレスをワークロードの特性に応じて使い分けることが多いです。

graph TD
    GW["API Gateway"]
    GW --> L1["Lambda<br/>認証処理"]
    GW --> L2["Lambda<br/>画像リサイズ"]
    GW --> ECS["ECS/Fargate<br/>メインAPI<br/>(常時起動)"]
    ECS --> RDS["RDS DB"]
    ECS --> L3["Lambda<br/>バッチ処理"]

    classDef gwStyle fill:#e8a838,stroke:#b07c1e,color:#fff
    classDef lambdaStyle fill:#5cb85c,stroke:#3d8b3d,color:#fff
    classDef ecsStyle fill:#4a90d9,stroke:#2c5f8a,color:#fff
    classDef dbStyle fill:#d9534f,stroke:#b52b27,color:#fff

    class GW gwStyle
    class L1,L2,L3 lambdaStyle
    class ECS ecsStyle
    class RDS dbStyle

ハイブリッドの判断フレームワーク

// ワークロードの特性に基づく計算プラットフォーム選定
interface Workload {
  name: string;
  characteristics: {
    requestPattern: 'CONSTANT' | 'SPIKY' | 'PERIODIC';
    executionDuration: 'SHORT' | 'MEDIUM' | 'LONG';   // <1s, 1s-5min, >5min
    stateRequirement: 'STATELESS' | 'STATEFUL';
    coldStartTolerance: 'LOW' | 'MEDIUM' | 'HIGH';
    monthlyInvocations: number;
  };
}

function recommendPlatform(workload: Workload): string {
  const { characteristics: c } = workload;

  // 長時間実行やステートフルはコンテナ
  if (c.executionDuration === 'LONG' || c.stateRequirement === 'STATEFUL') {
    return 'CONTAINER';
  }

  // コールドスタート耐性が低い常時トラフィックはコンテナ
  if (c.coldStartTolerance === 'LOW' && c.requestPattern === 'CONSTANT') {
    return 'CONTAINER';
  }

  // スパイクのあるショートリクエストはサーバーレス
  if (c.requestPattern === 'SPIKY' && c.executionDuration === 'SHORT') {
    return 'SERVERLESS';
  }

  // 低頻度の定期実行はサーバーレス
  if (c.requestPattern === 'PERIODIC' && c.monthlyInvocations < 100000) {
    return 'SERVERLESS';
  }

  // 高頻度の安定トラフィックはコンテナ
  if (c.monthlyInvocations > 10_000_000) {
    return 'CONTAINER';
  }

  return 'SERVERLESS';  // デフォルトはサーバーレス
}

// 適用例
const workloads: Workload[] = [
  {
    name: 'メインAPI',
    characteristics: {
      requestPattern: 'CONSTANT',
      executionDuration: 'SHORT',
      stateRequirement: 'STATELESS',
      coldStartTolerance: 'LOW',
      monthlyInvocations: 50_000_000,
    },
  },
  {
    name: '画像リサイズ',
    characteristics: {
      requestPattern: 'SPIKY',
      executionDuration: 'MEDIUM',
      stateRequirement: 'STATELESS',
      coldStartTolerance: 'HIGH',
      monthlyInvocations: 500_000,
    },
  },
  {
    name: '日次レポート生成',
    characteristics: {
      requestPattern: 'PERIODIC',
      executionDuration: 'LONG',
      stateRequirement: 'STATELESS',
      coldStartTolerance: 'HIGH',
      monthlyInvocations: 30,
    },
  },
];

workloads.forEach(w => {
  console.log(`${w.name}: ${recommendPlatform(w)}`);
});
// メインAPI: CONTAINER
// 画像リサイズ: SERVERLESS
// 日次レポート生成: CONTAINER(長時間実行のため)

まとめ

ポイント内容
コンテナ一貫した実行環境、柔軟な設定、運用負荷はやや高い
サーバーレス運用不要、従量課金、コールドスタートと実行時間制限あり
コスト低トラフィックはサーバーレス有利、高トラフィックはコンテナ有利
スケーリングサーバーレスは瞬時、コンテナは分単位
ハイブリッドワークロード特性に応じて使い分けるのが実用的
判断基準実行時間、トラフィックパターン、コールドスタート耐性、コスト

チェックリスト

  • コンテナアーキテクチャの基本構成を説明できる
  • Dockerfile のマルチステージビルドの目的を理解した
  • サーバーレスのコールドスタート問題を説明できる
  • コストモデルの違い(固定費 vs 従量課金)を理解した
  • ワークロードの特性に基づくプラットフォーム選定ができる
  • ハイブリッドアプローチの考え方を理解した

次のステップへ

次は「CQRSとイベントソーシング」を学びます。データの読み書きを分離し、イベントを活用した高度なアーキテクチャパターンを理解しましょう。


推定読了時間: 30分