T-CREATOR

NestJS デプロイ戦略:Blue-Green/Canary と DB マイグレーションの連携

NestJS デプロイ戦略:Blue-Green/Canary と DB マイグレーションの連携

本番環境へのデプロイは、開発者にとって最も緊張する瞬間の一つではないでしょうか。サービスのダウンタイムを最小限に抑えながら、新機能を安全にリリースする必要があります。

この記事では、NestJS アプリケーションにおける Blue-Green デプロイメントと Canary デプロイメントの実装方法、そして最も難易度の高いデータベースマイグレーションとの連携について詳しく解説します。実際のコード例とともに、本番運用で直面する課題とその解決策をご紹介しましょう。

背景

モダンなデプロイ戦略の必要性

従来の「サービスを停止してデプロイし、再起動する」方式では、ユーザーに不便を強いることになります。現代の Web サービスでは、24 時間 365 日の可用性が求められており、ゼロダウンタイムでのデプロイが必須要件となっています。

Blue-Green デプロイメントとは

Blue-Green デプロイメントは、本番環境(Blue)と同じ構成の待機環境(Green)を用意し、新バージョンを Green にデプロイ後、トラフィックを切り替える手法です。問題が発生した場合は即座に Blue へロールバックできるため、安全性が高いのが特徴ですね。

Canary デプロイメントとは

Canary デプロイメントは、新バージョンを一部のユーザーにのみ公開し、問題がないことを確認しながら段階的に全体へ展開する手法です。リスクを最小限に抑えながら、本番環境での動作を検証できます。

以下の図は、各デプロイ戦略の基本的なフローを示しています。

mermaidflowchart TD
    Start["デプロイ開始"] --> Strategy{"デプロイ戦略"}
    Strategy -->|Blue-Green| BG1["Green環境に<br/>新バージョンデプロイ"]
    Strategy -->|Canary| C1["一部ユーザーに<br/>新バージョン公開"]

    BG1 --> BG2["ヘルスチェック"]
    BG2 -->|成功| BG3["トラフィック切替<br/>Blue → Green"]
    BG2 -->|失敗| BG4["ロールバック"]

    C1 --> C2["メトリクス監視"]
    C2 -->|問題なし| C3["段階的に<br/>トラフィック増加"]
    C2 -->|問題あり| C4["ロールバック"]

    BG3 --> Done["デプロイ完了"]
    C3 --> Done
    BG4 --> End["デプロイ中止"]
    C4 --> End

上の図から分かるように、どちらの戦略も安全性を重視し、問題発生時のロールバック機能を備えています。

課題

データベースマイグレーションの難しさ

デプロイ戦略を実装する上で最大の課題は、データベースマイグレーションとの連携です。アプリケーションコードは簡単にロールバックできますが、データベーススキーマの変更は後戻りが困難な場合があります。

Blue-Green デプロイメントにおける課題

#課題詳細
1スキーマの互換性旧バージョン(Blue)と新バージョン(Green)が同時に稼働する期間、両方が同じ DB にアクセス可能である必要がある
2リソースコスト本番環境と同じ規模の待機環境を常時維持するため、インフラコストが倍増する
3データ同期切り替え中のトランザクションデータを確実に保持する必要がある

Canary デプロイメントにおける課題

Canary デプロイメントでは、旧バージョンと新バージョンが長期間並行稼働します。この期間中、データベーススキーマは両バージョンに対応していなければなりません。

以下の図は、DB マイグレーションとデプロイの関係を示しています。

mermaidsequenceDiagram
    participant V1 as アプリv1.0
    participant DB as データベース
    participant V2 as アプリv2.0

    Note over V1,DB: 通常運用
    V1->>DB: SELECT * FROM users
    DB-->>V1: 既存カラムで応答

    Note over DB,V2: マイグレーション実行
    V2->>DB: ALTER TABLE users<br/>ADD COLUMN new_field

    Note over V1,V2: 並行稼働期間
    V1->>DB: SELECT * FROM users
    DB-->>V1: 新カラムを無視して応答
    V2->>DB: SELECT * FROM users
    DB-->>V2: 新カラムを含めて応答

    Note over V1,V2: 問題発生!
    V2->>DB: INSERT ... (new_field)
    V1->>DB: SELECT ...
    V1->>V1: new_fieldが<br/>想定外でエラー

このシーケンス図が示すように、並行稼働期間中のスキーマ変更は慎重に設計する必要があります。

よくあるエラーと原因

デプロイ時に発生しやすいエラーを以下にまとめました。

#エラーコード発生条件原因
1ER_BAD_FIELD_ERROR: Unknown columnv1.0 が v2.0 のカラムを参照後方互換性のないマイグレーション
2ER_NO_DEFAULT_FOR_FIELDv1.0 が新しい NOT NULL カラムに値を入れないデフォルト値の未設定
3TypeORM: Cannot read property of undefinedエンティティ定義と DB スキーマの不一致マイグレーションタイミングの問題

解決策

後方互換性を保つマイグレーション戦略

データベースマイグレーションとデプロイを安全に連携させるには、「拡張フェーズ」と「収縮フェーズ」の 2 段階アプローチが効果的です。

拡張フェーズ(Expand Phase)

新しいスキーマを追加しますが、既存のスキーマは削除しません。この段階では、旧バージョンと新バージョンの両方が動作可能です。

収縮フェーズ(Contract Phase)

旧バージョンが完全に停止した後、不要になった古いスキーマを削除します。

以下の図は、このアプローチの全体像を表しています。

mermaidflowchart LR
    subgraph Phase1["拡張フェーズ"]
        E1["新カラム追加<br/>(NULL許可)"]
        E2["新テーブル追加"]
        E3["データ移行開始"]
    end

    subgraph Phase2["並行稼働"]
        P1["v1.0: 旧カラム使用"]
        P2["v2.0: 新カラム使用"]
        P3["両バージョン稼働"]
    end

    subgraph Phase3["収縮フェーズ"]
        C1["v1.0完全停止"]
        C2["旧カラム削除"]
        C3["制約追加"]
    end

    Phase1 --> Phase2
    Phase2 --> Phase3

NestJS での実装アプローチ

NestJS では、TypeORM または Prisma を使用してマイグレーションを管理できます。ここでは、TypeORM を使用した実装例をご紹介します。

ステップ 1:マイグレーション設定の準備

まず、TypeORM の設定ファイルを作成しましょう。

typescript// ormconfig.ts
import { DataSource } from 'typeorm';
import { config } from 'dotenv';

config();

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_DATABASE,
  entities: ['dist/**/*.entity.js'],
  migrations: ['dist/migrations/*.js'],
  synchronize: false, // 本番環境では必ずfalse
});

この設定では、synchronize: false を指定することで、自動スキーマ同期を無効化しています。本番環境では、マイグレーションスクリプトによる明示的な管理が必須です。

ステップ 2:後方互換性のあるマイグレーション作成

拡張フェーズのマイグレーションを作成します。新しいカラムは必ず NULL 許可またはデフォルト値を設定しましょう。

typescript// migrations/1234567890-AddEmailVerified.ts
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddEmailVerified1234567890 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // 拡張フェーズ:新カラムを追加(NULL許可)
    await queryRunner.addColumn(
      'users',
      new TableColumn({
        name: 'email_verified',
        type: 'boolean',
        isNullable: true, // 重要:既存レコードに影響を与えない
        default: false,
      }),
    );
  }

このマイグレーションでは、isNullable: truedefault: false を設定することで、既存の v1.0 アプリケーションが影響を受けないようにしています。

続いて、ダウンマイグレーションも実装します。

typescript  public async down(queryRunner: QueryRunner): Promise<void> {
    // ロールバック時は追加したカラムを削除
    await queryRunner.dropColumn('users', 'email_verified');
  }
}

ステップ 3:エンティティの段階的更新

新バージョン(v2.0)のエンティティでは、新しいカラムを利用します。ただし、旧バージョンとの互換性を考慮した実装が必要です。

typescript// entities/user.entity.ts (v2.0)
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  // 新しいカラム:NULLの可能性を考慮
  @Column({ nullable: true, default: false })
  emailVerified?: boolean;
}

emailVerified をオプショナル(?)にすることで、古いデータにも対応できますね。

ステップ 4:デプロイスクリプトの作成

Blue-Green デプロイメントを実現するためのスクリプトを作成します。まず、デプロイ前のヘルスチェック機能を実装しましょう。

typescript// scripts/health-check.ts
import axios from 'axios';

async function healthCheck(url: string, retries = 30): Promise<boolean> {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await axios.get(`${url}/health`);

      if (response.status === 200 && response.data.status === 'ok') {
        console.log('✓ ヘルスチェック成功');
        return true;
      }
    } catch (error) {
      console.log(`ヘルスチェック待機中... (${i + 1}/${retries})`);
    }

このスクリプトは、新しくデプロイされた環境が正常に起動するまで待機します。

typescript    // 5秒待機してリトライ
    await new Promise(resolve => setTimeout(resolve, 5000));
  }

  console.error('✗ ヘルスチェック失敗');
  return false;
}

export default healthCheck;

ステップ 5:Blue-Green デプロイメントスクリプト

Docker Compose を使用した Blue-Green デプロイメントの実装例です。

bash#!/bin/bash
# scripts/blue-green-deploy.sh

set -e

CURRENT_ENV=$(cat .current-env)
NEW_ENV="green"

if [ "$CURRENT_ENV" == "green" ]; then
  NEW_ENV="blue"
fi

echo "現在の環境: $CURRENT_ENV"
echo "デプロイ先: $NEW_ENV"

まず、現在アクティブな環境を確認し、切り替え先の環境を決定します。

次に、マイグレーションを実行してから新環境を起動します。

bash# マイグレーション実行(拡張フェーズ)
echo "マイグレーション実行中..."
docker-compose run --rm migration yarn migration:run

# 新環境のコンテナを起動
echo "$NEW_ENV 環境を起動中..."
docker-compose up -d app-$NEW_ENV

# ヘルスチェック
echo "ヘルスチェック中..."
node dist/scripts/health-check.js http://localhost:300${NEW_ENV:0:1}

ヘルスチェックが成功したら、ロードバランサーの設定を切り替えます。

bashif [ $? -eq 0 ]; then
  # トラフィックを新環境に切り替え
  echo "トラフィックを $NEW_ENV に切り替え中..."
  ./scripts/switch-traffic.sh $NEW_ENV

  # 旧環境を停止
  sleep 30  # 既存接続の完了を待つ
  docker-compose stop app-$CURRENT_ENV

  # 現在の環境を更新
  echo $NEW_ENV > .current-env
  echo "✓ デプロイ完了"
else
  echo "✗ デプロイ失敗"
  exit 1
fi

ステップ 6:Canary デプロイメントの実装

Canary デプロイメントでは、Nginx やトラフィック管理ツールを使用して、段階的にトラフィックを新バージョンにルーティングします。

typescript// config/nginx-canary.conf
upstream backend {
    # 90%のトラフィックを安定版に
    server app-stable:3000 weight=90;

    # 10%のトラフィックをCanaryに
    server app-canary:3000 weight=10;
}

server {
    listen 80;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
    }
}

この設定では、weight パラメータを使用してトラフィックの割合を制御しています。

トラフィックの割合を動的に変更するスクリプトも作成しましょう。

bash#!/bin/bash
# scripts/canary-rollout.sh

CANARY_PERCENTAGE=$1

if [ -z "$CANARY_PERCENTAGE" ]; then
  echo "使用方法: ./canary-rollout.sh [0-100]"
  exit 1
fi

STABLE_WEIGHT=$((100 - CANARY_PERCENTAGE))

パーセンテージからウェイト値を計算します。

bash# Nginx設定を動的に生成
cat > /etc/nginx/conf.d/canary.conf <<EOF
upstream backend {
    server app-stable:3000 weight=${STABLE_WEIGHT};
    server app-canary:3000 weight=${CANARY_PERCENTAGE};
}
EOF

# Nginxをリロード
nginx -s reload

echo "Canaryトラフィック: ${CANARY_PERCENTAGE}%"

ステップ 7:モニタリングとロールバック

デプロイ後のモニタリングは欠かせません。NestJS でメトリクス収集の仕組みを実装します。

typescript// monitoring/metrics.service.ts
import { Injectable } from '@nestjs/common';
import { Counter, Histogram } from 'prom-client';

@Injectable()
export class MetricsService {
  private errorCounter: Counter;
  private responseTime: Histogram;

  constructor() {
    this.errorCounter = new Counter({
      name: 'http_errors_total',
      help: 'HTTPエラーの総数',
      labelNames: ['method', 'path', 'status'],
    });

エラーカウンターとレスポンスタイムのヒストグラムを初期化します。

typescript    this.responseTime = new Histogram({
      name: 'http_response_time_seconds',
      help: 'HTTPレスポンス時間',
      labelNames: ['method', 'path'],
      buckets: [0.1, 0.5, 1, 2, 5],
    });
  }

  recordError(method: string, path: string, status: number): void {
    this.errorCounter.inc({ method, path, status });
  }
}

これらのメトリクスを使用して、Canary デプロイメントの健全性を監視できます。

自動ロールバックの判定ロジックも実装しましょう。

typescript// monitoring/rollback.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { MetricsService } from './metrics.service';
import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

@Injectable()
export class RollbackService {
  private readonly logger = new Logger(RollbackService.name);
  private readonly ERROR_THRESHOLD = 0.05; // 5%のエラー率

  constructor(private metricsService: MetricsService) {}

エラー率の閾値を設定し、これを超えた場合に自動ロールバックを実行します。

typescript  async checkAndRollback(): Promise<void> {
    const metrics = await this.metricsService.getMetrics();
    const errorRate = metrics.errors / metrics.total;

    if (errorRate > this.ERROR_THRESHOLD) {
      this.logger.error(
        `エラー率が閾値を超過: ${errorRate * 100}%`,
      );
      await this.executeRollback();
    }
  }

エラー率が閾値を超えた場合の処理を実装します。

typescript  private async executeRollback(): Promise<void> {
    this.logger.warn('自動ロールバックを開始します');

    try {
      await execAsync('./scripts/rollback.sh');
      this.logger.log('ロールバック完了');
    } catch (error) {
      this.logger.error('ロールバック失敗', error);
    }
  }
}

具体例

実践的なユースケース:ユーザーテーブルへのカラム追加

実際のプロジェクトで、ユーザーテーブルに新しいカラムを追加しながら Blue-Green デプロイメントを実施する例を見ていきましょう。

シナリオ

  • 既存の v1.0 アプリケーションが稼働中
  • ユーザーテーブルに phone_number カラムを追加したい
  • ゼロダウンタイムでデプロイしたい

以下の図は、この実装の全体フローを示しています。

mermaidflowchart TD
    Start["v1.0稼働中"] --> M1["マイグレーション1:<br/>phone_number追加<br/>(NULL許可)"]
    M1 --> Deploy["v2.0をGreenに<br/>デプロイ"]
    Deploy --> Health["ヘルスチェック"]
    Health -->|成功| Switch["トラフィック切替"]
    Health -->|失敗| Rollback1["ロールバック"]

    Switch --> Monitor["24時間監視"]
    Monitor -->|問題なし| M2["マイグレーション2:<br/>v1.0停止後に<br/>NOT NULL制約追加"]
    Monitor -->|問題あり| Rollback2["v1.0に戻す"]

    M2 --> Complete["デプロイ完了"]
    Rollback1 --> End["デプロイ中止"]
    Rollback2 --> End

ステップ 1:拡張フェーズのマイグレーション作成

まず、NULL 許可の新カラムを追加するマイグレーションを作成します。

typescript// migrations/1234567891-AddPhoneNumber.ts
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddPhoneNumber1234567891 implements MigrationInterface {
  name = 'AddPhoneNumber1234567891';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.addColumn(
      'users',
      new TableColumn({
        name: 'phone_number',
        type: 'varchar',
        length: '20',
        isNullable: true, // v1.0との互換性のため
      }),
    );
  }

このように、isNullable: true を指定することで、既存の v1.0 アプリケーションが影響を受けません。

typescript  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropColumn('users', 'phone_number');
  }
}

ステップ 2:v2.0 のエンティティ更新

新しいカラムを使用するエンティティを定義します。

typescript// entities/user.entity.ts (v2.0)
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
} from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  @Column({ nullable: true })
  phoneNumber?: string; // オプショナルにする
}

phoneNumber をオプショナルにすることで、マイグレーション直後のデータ(phone_number が NULL)にも対応できます。

ステップ 3:アプリケーションロジックの実装

新しいカラムを使用するサービスを実装しましょう。

typescript// users/users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

ユーザーサービスのコンストラクタでリポジトリを注入します。

typescript  async updatePhoneNumber(
    userId: number,
    phoneNumber: string,
  ): Promise<User> {
    const user = await this.usersRepository.findOne({
      where: { id: userId },
    });

    if (!user) {
      throw new Error('User not found');
    }

    user.phoneNumber = phoneNumber;
    return this.usersRepository.save(user);
  }
}

この実装では、既存のユーザーに対して電話番号を追加できますね。

ステップ 4:完全なデプロイスクリプト

実際に使用するデプロイスクリプトの完全版を見ていきましょう。

bash#!/bin/bash
# deploy.sh - 完全版Blue-Greenデプロイメントスクリプト

set -euo pipefail

# 設定
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
CURRENT_ENV=$(cat "$PROJECT_ROOT/.current-env" || echo "blue")
NEW_ENV="green"

if [ "$CURRENT_ENV" == "green" ]; then
  NEW_ENV="blue"
fi

環境変数と現在のアクティブ環境を設定します。

bashecho "========================================="
echo "Blue-Greenデプロイメント開始"
echo "========================================="
echo "現在の環境: $CURRENT_ENV"
echo "デプロイ先: $NEW_ENV"
echo "========================================="

# ステップ1: マイグレーション実行
echo "[1/6] データベースマイグレーション実行中..."
docker-compose run --rm migration yarn migration:run

if [ $? -ne 0 ]; then
  echo "✗ マイグレーション失敗"
  exit 1
fi
echo "✓ マイグレーション完了"

まず、データベースマイグレーションを実行します。これは拡張フェーズのマイグレーションです。

bash# ステップ2: 新環境のビルド
echo "[2/6] $NEW_ENV 環境のDockerイメージビルド中..."
docker-compose build app-$NEW_ENV

echo "✓ ビルド完了"

# ステップ3: 新環境の起動
echo "[3/6] $NEW_ENV 環境を起動中..."
docker-compose up -d app-$NEW_ENV

sleep 10  # 起動待ち

新しい環境の Docker イメージをビルドし、コンテナを起動します。

bash# ステップ4: ヘルスチェック
echo "[4/6] ヘルスチェック実行中..."
HEALTH_URL="http://localhost:300$([ "$NEW_ENV" == "blue" ] && echo "1" || echo "2")"

for i in {1..30}; do
  if curl -f "$HEALTH_URL/health" > /dev/null 2>&1; then
    echo "✓ ヘルスチェック成功"
    break
  fi

  if [ $i -eq 30 ]; then
    echo "✗ ヘルスチェックタイムアウト"
    docker-compose logs app-$NEW_ENV
    docker-compose stop app-$NEW_ENV
    exit 1
  fi

  echo "  待機中... ($i/30)"
  sleep 5
done

新環境が正常に起動したことを確認するため、ヘルスチェックを実施します。

bash# ステップ5: トラフィック切り替え
echo "[5/6] トラフィックを $NEW_ENV に切り替え中..."
./scripts/switch-traffic.sh $NEW_ENV

if [ $? -ne 0 ]; then
  echo "✗ トラフィック切り替え失敗"
  exit 1
fi

echo "✓ トラフィック切り替え完了"
sleep 30  # 既存接続の完了を待つ

ロードバランサーの設定を変更して、トラフィックを新環境に切り替えます。

bash# ステップ6: 旧環境の停止
echo "[6/6] $CURRENT_ENV 環境を停止中..."
docker-compose stop app-$CURRENT_ENV

# 現在の環境を更新
echo $NEW_ENV > "$PROJECT_ROOT/.current-env"

echo "========================================="
echo "✓ デプロイ完了"
echo "アクティブ環境: $NEW_ENV"
echo "========================================="

ステップ 5:ロールバックスクリプト

問題が発生した場合に備えて、ロールバックスクリプトも用意します。

bash#!/bin/bash
# rollback.sh - ロールバックスクリプト

set -euo pipefail

CURRENT_ENV=$(cat .current-env)
PREVIOUS_ENV="blue"

if [ "$CURRENT_ENV" == "blue" ]; then
  PREVIOUS_ENV="green"
fi

echo "========================================="
echo "ロールバック開始"
echo "========================================="
echo "現在の環境: $CURRENT_ENV$PREVIOUS_ENV"

現在の環境と戻す先の環境を特定します。

bash# 前の環境を再起動
echo "[1/3] $PREVIOUS_ENV 環境を起動中..."
docker-compose up -d app-$PREVIOUS_ENV
sleep 10

# ヘルスチェック
echo "[2/3] ヘルスチェック中..."
HEALTH_URL="http://localhost:300$([ "$PREVIOUS_ENV" == "blue" ] && echo "1" || echo "2")"

if ! curl -f "$HEALTH_URL/health" > /dev/null 2>&1; then
  echo "✗ ロールバック失敗"
  exit 1
fi

前の環境を起動し、正常に動作することを確認します。

bash# トラフィックを戻す
echo "[3/3] トラフィックを $PREVIOUS_ENV に戻し中..."
./scripts/switch-traffic.sh $PREVIOUS_ENV

# 現在の環境を停止
docker-compose stop app-$CURRENT_ENV

# 環境ファイルを更新
echo $PREVIOUS_ENV > .current-env

echo "========================================="
echo "✓ ロールバック完了"
echo "========================================="

ステップ 6:収縮フェーズのマイグレーション

v1.0 が完全に停止し、v2.0 のみが稼働している状態になったら、収縮フェーズのマイグレーションを実行できます。

typescript// migrations/1234567892-MakePhoneNumberRequired.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class MakePhoneNumberRequired1234567892 implements MigrationInterface {
  name = 'MakePhoneNumberRequired1234567892';

  public async up(queryRunner: QueryRunner): Promise<void> {
    // NULLのレコードにデフォルト値を設定
    await queryRunner.query(
      `UPDATE users SET phone_number = '' WHERE phone_number IS NULL`
    );

まず、NULL のレコードにデフォルト値を設定します。

typescript    // NOT NULL制約を追加
    await queryRunner.query(
      `ALTER TABLE users ALTER COLUMN phone_number SET NOT NULL`
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TABLE users ALTER COLUMN phone_number DROP NOT NULL`
    );
  }
}

これで、phone_number カラムが必須フィールドになりました。

エラー処理とトラブルシューティング

ケース 1:マイグレーション失敗時のエラー

エラーコード: QueryFailedError: ER_DUP_FIELDNAME: Duplicate column name 'phone_number'

発生条件: 既に同じカラムが存在する状態でマイグレーションを実行

解決方法:

  1. 既存のマイグレーション状態を確認
bashyarn migration:show
  1. 重複したマイグレーションをリバートする
bashyarn migration:revert
  1. マイグレーションを再実行する
bashyarn migration:run

ケース 2:ヘルスチェック失敗

エラーコード: ECONNREFUSED: Connection refused

発生条件: アプリケーションの起動が完了していない、またはポートが間違っている

解決方法:

  1. コンテナのログを確認
bashdocker-compose logs app-green
  1. アプリケーションの起動エラーがないか確認
typescript// main.ts - エラーハンドリングを追加
async function bootstrap() {
  try {
    const app = await NestFactory.create(AppModule);
    await app.listen(3000);
    console.log('アプリケーション起動完了');
  } catch (error) {
    console.error('起動エラー:', error);
    process.exit(1);
  }
}
  1. データベース接続を確認
bashdocker-compose exec postgres psql -U myuser -d mydb -c "\dt"

ケース 3:トラフィック切り替え後のエラー増加

エラーコード: TypeError: Cannot read property 'phoneNumber' of undefined

発生条件: v2.0 のコードが NULL を適切に処理していない

解決方法:

  1. 即座にロールバックを実行
bash./scripts/rollback.sh
  1. コードに NULL チェックを追加
typescript// users/users.controller.ts
@Get(':id/phone')
async getPhoneNumber(@Param('id') id: number) {
  const user = await this.usersService.findOne(id);

  // NULLチェックを追加
  if (!user) {
    throw new NotFoundException('ユーザーが見つかりません');
  }

  return {
    phoneNumber: user.phoneNumber ?? '未設定',
  };
}
  1. 修正後、再度デプロイを実行

Docker Compose 設定の完全版

最後に、Blue-Green デプロイメントを実現するための Docker Compose 設定をご紹介します。

yaml# docker-compose.yml
version: '3.8'

services:
  # データベース(共有)
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
      POSTGRES_DB: mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

データベースは両環境で共有します。

yaml# Blue環境
app-blue:
  build:
    context: .
    dockerfile: Dockerfile
  environment:
    NODE_ENV: production
    DB_HOST: postgres
    DB_PORT: 5432
    DB_USERNAME: myuser
    DB_PASSWORD: mypassword
    DB_DATABASE: mydb
  ports:
    - '3001:3000'
  depends_on:
    - postgres

Blue 環境はポート 3001 で公開します。

yaml# Green環境
app-green:
  build:
    context: .
    dockerfile: Dockerfile
  environment:
    NODE_ENV: production
    DB_HOST: postgres
    DB_PORT: 5432
    DB_USERNAME: myuser
    DB_PASSWORD: mypassword
    DB_DATABASE: mydb
  ports:
    - '3002:3000'
  depends_on:
    - postgres

Green 環境はポート 3002 で公開します。

yaml  # マイグレーション実行用
  migration:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      NODE_ENV: production
      DB_HOST: postgres
      DB_PORT: 5432
      DB_USERNAME: myuser
      DB_PASSWORD: mypassword
      DB_DATABASE: mydb
    depends_on:
      - postgres
    command: yarn migration:run

volumes:
  postgres_data:

まとめ

この記事では、NestJS アプリケーションにおける Blue-Green デプロイメントと Canary デプロイメントの実装方法、そしてデータベースマイグレーションとの連携について詳しく解説しました。

重要なポイント

#項目ポイント
1マイグレーション戦略拡張フェーズと収縮フェーズの 2 段階アプローチで後方互換性を確保する
2Blue-Green デプロイヘルスチェックとロールバック機能を必ず実装する
3Canary デプロイトラフィックを段階的に増やし、メトリクスを監視する
4エラー処理NULL チェックと適切なデフォルト値の設定が重要
5モニタリングエラー率を監視し、閾値を超えたら自動ロールバック

実装の流れ

  1. 拡張フェーズ: 新しいスキーマを NULL 許可で追加し、両バージョンが動作可能な状態を作る
  2. デプロイ: ヘルスチェックを実施してから、トラフィックを切り替える
  3. モニタリング: エラー率やレスポンスタイムを監視し、問題があればロールバック
  4. 収縮フェーズ: 旧バージョンが完全に停止したら、不要なスキーマを削除

今後の発展

本記事で紹介した基本的な実装をベースに、以下のような拡張も検討できます。

  • Kubernetes を使用した自動スケーリング対応
  • A/B テストとの統合
  • 複数リージョンへの段階的デプロイ
  • Feature フラグによるきめ細かいリリースコントロール

安全で確実なデプロイ戦略を実装することで、開発チームは自信を持って新機能をリリースできるようになります。本番環境へのデプロイも、もう怖くありませんね。

ぜひ、この記事を参考に、あなたのプロジェクトでもゼロダウンタイムデプロイメントを実現してください。

関連リンク