Tasuke Hubのロゴ

ITを中心に困っている人を助けるメディア

分かりやすく解決策を提供することで、あなたの困ったをサポート。 全ての人々がスムーズに生活できる世界を目指します。

Dockerコンテナ内TypeScriptプロジェクトのデバッグ技法

記事のサムネイル

Dockerコンテナ内TypeScriptプロジェクトのデバッグ環境を構築する

Dockerコンテナ内でのTypeScriptプロジェクトのデバッグは、開発者にとって頭を悩ませる問題の一つです。ローカルで直接実行する場合に比べて、コンテナという隔離された環境でデバッグを行うには、いくつかの追加設定が必要になります。

まずは基本的なDockerfile設定から始めましょう。TypeScriptプロジェクトのデバッグに最適化された構成を作成します。

FROM node:18-alpine

WORKDIR /app

# タイムゾーンを設定
ENV TZ=Asia/Tokyo

# デバッグに必要なツールをインストール
RUN apk add --no-cache tini curl

# TypeScriptとデバッグに便利なパッケージをグローバルにインストール
RUN npm install -g typescript ts-node nodemon

# パッケージファイルをコピーして依存関係をインストール
COPY package*.json ./
RUN npm install

# ソースコードをコピー
COPY . .

# デバッグモードで起動するコマンド
CMD ["node", "--inspect=0.0.0.0:9229", "dist/index.js"]

次に、docker-compose.ymlファイルでデバッグ用の設定を追加します。

version: '3.8'

services:
  app:
    build: 
      context: .
      dockerfile: Dockerfile
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "3000:3000"  # アプリケーションポート
      - "9229:9229"  # デバッグポート
    environment:
      - NODE_ENV=development
    command: npm run dev:debug

package.jsonにデバッグ用のスクリプトを追加します。

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts",
    "dev:debug": "nodemon --watch 'src/**/*.ts' --exec 'node --inspect=0.0.0.0:9229 -r ts-node/register src/index.ts'"
  }
}

これらの設定により、Dockerコンテナ内でTypeScriptプロジェクトを実行しながら、ホストマシンからデバッグできる環境が整います。--inspect=0.0.0.0:9229オプションにより、コンテナ外からのデバッグ接続を許可し、9229ポートでデバッグサーバーを起動します。

VSCodeとDockerの連携によるリモートデバッグの設定方法

Docker環境が準備できたら、次はVSCodeと連携してリモートデバッグ環境を構築します。VSCodeは強力なデバッグ機能を備えており、Dockerコンテナ内のTypeScriptプロジェクトをシームレスにデバッグすることができます。

まず、VSCodeの.vscode/launch.jsonファイルを作成または編集します。次の設定を追加しましょう:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "attach",
      "name": "Docker: Attach to Node",
      "remoteRoot": "/app",
      "localRoot": "${workspaceFolder}",
      "port": 9229,
      "restart": true,
      "address": "localhost",
      "skipFiles": [
        "<node_internals>/**",
        "${workspaceFolder}/node_modules/**"
      ],
      "outFiles": [
        "${workspaceFolder}/dist/**/*.js"
      ],
      "sourceMaps": true
    }
  ]
}

この設定では、以下の重要なポイントがあります:

  1. request: "attach": すでに実行中のNode.jsプロセスにアタッチする設定です
  2. remoteRoot: Dockerコンテナ内のプロジェクトパス
  3. localRoot: ローカルマシン上のプロジェクトパス
  4. port: デバッグポート(9229)
  5. sourceMaps: ソースマップを有効にしてTypeScriptとコンパイル後のJavaScriptをマッピング

次に、VSCodeでTypeScriptプロジェクトのデバッグに必要な設定として、tsconfig.jsonを適切に設定します。

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "sourceMap": true,
    "inlineSources": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

ここで重要なのは、sourceMapinlineSourcesオプションをtrueに設定することです。これにより、コンパイルされたJavaScriptとTypeScriptソースコード間のマッピングが可能になり、VSCodeのデバッガーがTypeScriptファイルでブレークポイントを正確に処理できるようになります。

デバッグを開始するには、まずDockerコンテナを起動します:

docker-compose up

そして、VSCodeのデバッグビューを開き、「Docker: Attach to Node」を選択して実行します。これでVSCodeのデバッガーがDockerコンテナ内のNode.jsプロセスに接続され、TypeScriptのデバッグが可能になります。

ブレークポイントが効かない問題とその解決法

Dockerコンテナ内のTypeScriptプロジェクトをデバッグしようとすると、ブレークポイントが正しく動作しないという問題に直面することがあります。この問題はいくつかの理由で発生する可能性があり、以下に一般的な問題とその解決策を紹介します。

1. ソースマップの問題

最も一般的な問題の一つは、ソースマップの設定が正しくないことです。

問題の兆候

  • ブレークポイントがグレーで表示される(有効化されていない)
  • デバッグコンソールに「Breakpoint ignored because generated code not found」というメッセージが表示される

解決策

まず、tsconfig.jsonに正しいソースマップの設定があることを確認します:

{
  "compilerOptions": {
    "sourceMap": true,
    "inlineSources": true
  }
}

次に、ビルドスクリプトがソースマップを正しく生成していることを確認します。例えば、Webpackを使用している場合は、webpack.config.jsに以下の設定を追加します:

module.exports = {
  // ...
  devtool: 'source-map',
  // ...
};

2. ファイルパスの不一致

問題の兆候

  • コンテナ内のファイルパスとローカルのファイルパスが一致しない
  • デバッガーがソースファイルを見つけられない

解決策

launch.jsonで、remoteRootlocalRootの設定を正しく行います:

{
  "configurations": [
    {
      "remoteRoot": "/app",
      "localRoot": "${workspaceFolder}"
    }
  ]
}

ここで、remoteRootはコンテナ内のプロジェクトルートディレクトリのパスで、localRootはローカルマシン上のプロジェクトルートディレクトリのパスです。

また、volume mountsが正しく設定されていることを確認します:

volumes:
  - .:/app
  - /app/node_modules

3. インスペクターの問題

問題の兆候

  • デバッガーがNode.jsプロセスに接続できない
  • デバッグポート(9229)に接続できない

解決策

Node.jsプロセスが正しくインスペクターを有効にして起動していることを確認します:

# パッケージ起動時
node --inspect=0.0.0.0:9229 dist/index.js

# ts-nodeを使用している場合
node --inspect=0.0.0.0:9229 -r ts-node/register src/index.ts

また、docker-compose.ymlでポートフォワーディングが正しく設定されていることを確認します:

ports:
  - "9229:9229"

4. タイミングの問題

問題の兆候

  • 初期化時にブレークポイントが効かない
  • コードの一部分のみブレークポイントが効く

解決策

VSCodeのlaunch.jsonにrestart: trueオプションを追加して、デバッガーが自動的に再接続するようにします:

{
  "configurations": [
    {
      "restart": true
    }
  ]
}

さらに、プログラムの開始時にブレークポイントを効かせるには、デバッガーが接続するまで待機するコードを追加します:

// デバッグ環境でのみ実行するコード
if (process.env.NODE_ENV === 'development') {
  console.log('Debugger waiting for connection...');
  // 少し遅延させてデバッガーが接続する時間を確保
  setTimeout(() => {
    console.log('Continuing execution...');
  }, 1000);
}

これらの解決策を実施しても問題が解決しない場合は、Node.jsのバージョンをチェックし、最新の安定版を使用していることを確認してください。また、VSCodeとそのNode.jsデバッグ拡張機能も最新バージョンにアップデートするとよいでしょう。

ホットリロードとデバッグを両立させるテクニック

Dockerコンテナ内でTypeScriptプロジェクトを開発する際、コードの変更を即座に反映するホットリロード機能とデバッグを同時に行いたいニーズは非常に高いです。しかし、これらを組み合わせると様々な問題が発生することがあります。ここでは、ホットリロードとデバッグを効果的に両立させるテクニックを紹介します。

nodemonとデバッグの連携

nodemonはファイルの変更を監視して、自動的にアプリケーションを再起動するツールです。TypeScriptのデバッグ環境と組み合わせるには、以下のような設定が効果的です。

package.jsonの設定例

{
  "scripts": {
    "dev:debug": "nodemon --watch 'src/**/*.ts' --inspect=0.0.0.0:9229 --exec 'node -r ts-node/register' src/index.ts"
  }
}

しかし、nodemonはアプリケーションを再起動するため、デバッグセッションも切断されてしまいます。これを解決するには、VSCodeのlaunch.jsonにrestart: trueを設定し、デバッガーが自動的に再接続するようにします。

{
  "configurations": [
    {
      "restart": true,
      "restartArgs": ["--inspect=0.0.0.0:9229"]
    }
  ]
}

ts-node-devの活用

ts-node-devは、ts-nodeとnodemonの機能を組み合わせたツールで、TypeScriptプロジェクトのホットリロードに最適化されています。Dockerコンテナ内でのデバッグと組み合わせる例は以下の通りです。

package.jsonの設定例

{
  "scripts": {
    "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
    "dev:debug": "ts-node-dev --respawn --inspect=0.0.0.0:9229 src/index.ts"
  }
}

--respawnオプションは、プロセスが終了した後に自動的に再起動する機能を有効にします。--transpile-onlyは型チェックをスキップして起動時間を短縮します。

docker-compose.ymlの設定

services:
  app:
    # ...
    command: npm run dev:debug
    environment:
      - NODE_ENV=development
      - TS_NODE_DEV_RESTART_TIMEOUT=100

TS_NODE_DEV_RESTART_TIMEOUT環境変数は、ファイル変更後の再起動までの待機時間をミリ秒単位で指定します。Docker環境では、ファイル同期のレイテンシを考慮して適切な値を設定することが重要です。

TypeScriptコンパイルのパフォーマンス最適化

コンテナ内でのTypeScriptコンパイルは、特に大規模なプロジェクトでは遅くなることがあります。以下の最適化テクニックを適用すると、デバッグ体験を向上させることができます。

  1. 増分コンパイルの有効化

    // tsconfig.json
    {
      "compilerOptions": {
        "incremental": true,
        "tsBuildInfoFile": "./dist/.tsbuildinfo"
      }
    }
  2. 不要なファイルの除外

    // tsconfig.json
    {
      "exclude": ["node_modules", "**/*.test.ts", "dist"]
    }
  3. esbuildを使用したトランスパイルesbuild-node-tscなどのツールを使用すると、標準のTypeScriptコンパイラよりもはるかに高速にトランスパイルできます。

    // package.json
    {
      "scripts": {
        "build": "esbuild-node-tsc",
        "dev:debug": "nodemon --watch 'src/**/*.ts' --exec 'esbuild-node-tsc && node --inspect=0.0.0.0:9229 dist/index.js'"
      }
    }

これらのテクニックを組み合わせることで、Dockerコンテナ内でのTypeScript開発におけるホットリロードとデバッグの両立が可能になります。効率的な開発ワークフローを実現するためには、プロジェクトの規模や要件に応じて最適な組み合わせを選択することが重要です。

コンテナ内部でのログ収集と分析の効率化

Dockerコンテナ内でTypeScriptアプリケーションをデバッグする際、効率的なログ収集と分析は非常に重要です。適切なログを取得・分析することで、問題の早期発見や解決が容易になります。

構造化ログの実装

コンテナ環境では、ログを構造化フォーマットで出力することで、後からの分析が容易になります。TypeScriptプロジェクトでは、winstonpinoなどのログライブラリを使用することをお勧めします。

pinologgerの設定例

// src/utils/logger.ts
import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // JSON形式でログを出力
  formatters: {
    level: (label) => {
      return { level: label };
    },
    // リクエストID、ユーザーID、トレースIDなどをログに含める
    bindings: (bindings) => {
      return {
        pid: bindings.pid,
        hostname: bindings.hostname,
        containerName: process.env.CONTAINER_NAME,
      };
    },
  },
  // タイムスタンプをISOフォーマットで出力
  timestamp: pino.stdTimeFunctions.isoTime,
});

export default logger;

アプリケーション内でのログ出力例:

import logger from './utils/logger';

// ログコンテキストを設定(リクエストごとの一意の情報など)
const requestLogger = logger.child({ requestId: '123', userId: '456' });

try {
  // 何らかの処理
  requestLogger.info('処理が成功しました', { extraData: { result: 'success' } });
} catch (error) {
  requestLogger.error('エラーが発生しました', { 
    error: { 
      message: error.message, 
      stack: error.stack 
    } 
  });
}

コンテナログの収集と転送

Dockerコンテナからログを効率的に収集するには、以下のアプローチがあります:

  1. 標準出力への書き込み: コンテナ環境では、アプリケーションのログを標準出力と標準エラー出力に書き込むことが推奨されています。Dockerは、これらの出力を自動的に収集します。

    // Node.jsの標準出力へのログ記録を設定
    const logger = pino({
      transport: {
        target: 'pino-pretty',
        options: {
          colorize: process.env.NODE_ENV !== 'production',
        },
      },
    });
  2. ログボリュームのマウント: 重要なログファイルをホストマシンと共有するには、専用のボリュームをマウントします。

    # docker-compose.yml
    services:
      app:
        # ...
        volumes:
          - .:/app
          - ./logs:/app/logs
          - /app/node_modules

リアルタイムデバッグ用のログ監視

デバッグ中にリアルタイムでログを監視するには、以下のテクニックが役立ちます:

  1. コンテナログのリアルタイム表示

    docker-compose logs -f app
  2. VSCodeでのログストリームの統合: VSCodeのデバッグ設定で、ログ出力を統合することができます。

    // .vscode/launch.json
    {
      "configurations": [
        {
          "name": "Docker: Attach to Node",
          "type": "node",
          "request": "attach",
          "console": "integratedTerminal",
          "internalConsoleOptions": "neverOpen",
          // ...他の設定
        }
      ]
    }

ログ分析ツールの活用

大量のログを効率的に分析するには、専用のツールを使用すると便利です:

  1. コンテナ内での一時的なログ分析

    # コンテナ内で実行
    cat logs/app.log | grep "ERROR" | tail -n 100
  2. jqを使用したJSON形式ログの分析: 構造化ログの分析には、jqが非常に便利です。

    # エラーログのみを抽出
    cat logs/app.log | jq 'select(.level == "error")'
    
    # 特定のリクエストIDに関連するログを抽出
    cat logs/app.log | jq 'select(.requestId == "123")'
  3. デバッグセッション中にログレベルを動的に変更: 詳細なデバッグ情報が必要な場合に、実行中のアプリケーションのログレベルを変更するエンドポイントを実装します。

    // src/utils/debug-endpoints.ts
    import express from 'express';
    import logger from './logger';
    
    const router = express.Router();
    
    // デバッグ用のエンドポイント(開発環境でのみ有効)
    if (process.env.NODE_ENV === 'development') {
      router.post('/debug/log-level', (req, res) => {
        const { level } = req.body;
        if (['trace', 'debug', 'info', 'warn', 'error', 'fatal'].includes(level)) {
          logger.level = level;
          res.send({ success: true, message: `ログレベルを ${level} に変更しました` });
        } else {
          res.status(400).send({ success: false, message: '無効なログレベルです' });
        }
      });
    }
    
    export default router;

これらのテクニックを組み合わせることで、Dockerコンテナ内のTypeScriptアプリケーションのデバッグが効率化され、問題の早期発見と解決に役立ちます。

本番環境を模倣したデバッグ環境の構築と活用法

開発環境で発見できないバグが本番環境で発生することはよくある問題です。そのため、本番環境をできるだけ忠実に再現したデバッグ環境を構築することが重要です。Dockerを活用することで、このような環境を効率的に作成できます。

マルチステージビルドを活用した本番環境の再現

本番環境と開発環境の差異を最小限に抑えるために、Dockerのマルチステージビルドを活用することができます。

# ベースステージ
FROM node:18-alpine AS base
WORKDIR /app
ENV TZ=Asia/Tokyo

# 依存関係インストールステージ
FROM base AS deps
COPY package*.json ./
RUN npm ci

# ビルドステージ
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 開発ステージ
FROM base AS development
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["npm", "run", "dev:debug"]

# 本番ステージ
FROM base AS production
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

# デバッグ本番ステージ
FROM base AS production-debug
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
# 本番環境と同じコマンドだが、デバッグフラグを追加
CMD ["node", "--inspect=0.0.0.0:9229", "dist/index.js"]

この設定により、開発環境と本番環境で同じベースイメージと依存関係を使用し、環境差異による問題を減らすことができます。

Docker Composeを使用した環境の切り替え

docker-compose.ymlファイルを複数用意することで、様々な環境を簡単に切り替えることができます。

docker-compose.yml(開発用)

version: '3.8'

services:
  app:
    build:
      context: .
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "3000:3000"
      - "9229:9229"
    environment:
      - NODE_ENV=development

docker-compose.prod.yml(本番デバッグ用)

version: '3.8'

services:
  app:
    build:
      context: .
      target: production-debug
    ports:
      - "3000:3000"
      - "9229:9229"
    environment:
      - NODE_ENV=production
      # 本番環境の環境変数をモック
      - DB_HOST=db
      - REDIS_HOST=redis
      - API_KEY=${API_KEY}
  
  # 必要な依存サービス(データベース、キャッシュなど)
  db:
    image: postgres:14
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=app
    volumes:
      - postgres-data:/var/lib/postgresql/data
  
  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:

本番デバッグ環境を起動するには、次のコマンドを使用します:

docker-compose -f docker-compose.prod.yml up

環境固有の問題をデバッグするためのテクニック

1. 環境変数を利用した条件付きデバッグコード

本番環境特有の問題をデバッグするには、環境変数を使用して条件付きでデバッグコードを実行する方法が有効です。

// デバッグログを追加(本番デバッグモードでのみ有効)
if (process.env.NODE_ENV === 'production' && process.env.DEBUG === 'true') {
  console.log('Production Debug Mode:', {
    config: getConfig(),
    environment: process.env,
    // 機密情報を含まないようにマスク処理を行う
    maskedData: maskSensitiveData(getData())
  });
}
2. リクエスト/レスポンスのモニタリング

本番環境でのAPI通信をモニタリングするためのミドルウェアを追加します。

// src/middlewares/request-logger.ts
import { Request, Response, NextFunction } from 'express';
import logger from '../utils/logger';

export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
  // オリジナルのレスポンスメソッドを保存
  const originalSend = res.send;
  
  // リクエストの開始時刻を記録
  const startTime = Date.now();
  
  // リクエスト情報をログに記録
  logger.info({
    type: 'request',
    method: req.method,
    url: req.url,
    headers: req.headers,
    query: req.query,
    // ボディに機密情報が含まれる可能性があるため、必要に応じてマスク処理
    body: maskSensitiveData(req.body),
  });

  // レスポンスメソッドをオーバーライド
  res.send = function(body) {
    // 処理時間を計算
    const duration = Date.now() - startTime;
    
    // レスポンス情報をログに記録
    logger.info({
      type: 'response',
      statusCode: res.statusCode,
      duration,
      headers: res.getHeaders(),
      // レスポンスボディも必要に応じてマスク処理
      body: typeof body === 'string' ? body.substring(0, 100) + '...' : '[非文字列データ]',
    });
    
    // オリジナルのsendメソッドを呼び出す
    return originalSend.call(this, body);
  };
  
  next();
};
3. パフォーマンスプロファイリング

本番環境での性能問題を検出するために、Node.jsの組み込みプロファイラーを使用します。

// src/utils/profiler.ts
import * as fs from 'fs';
import * as path from 'path';
import * as v8Profiler from 'v8-profiler-next';

export class Profiler {
  private static instance: Profiler;
  private isProfiling = false;
  
  private constructor() {}
  
  public static getInstance(): Profiler {
    if (!Profiler.instance) {
      Profiler.instance = new Profiler();
    }
    return Profiler.instance;
  }
  
  public startCPUProfiling(duration = 30000): void {
    if (this.isProfiling) {
      console.warn('Profiling already in progress');
      return;
    }
    
    this.isProfiling = true;
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const profileName = `cpu-profile-${timestamp}`;
    
    console.log(`Starting CPU profiling (${duration}ms): ${profileName}`);
    
    // プロファイリング開始
    v8Profiler.startProfiling(profileName, true);
    
    // 指定した時間後にプロファイリングを停止
    setTimeout(() => {
      const profile = v8Profiler.stopProfiling(profileName);
      
      // プロファイル結果を保存
      const outputDir = path.join(process.cwd(), 'profiles');
      if (!fs.existsSync(outputDir)) {
        fs.mkdirSync(outputDir, { recursive: true });
      }
      
      const outputFile = path.join(outputDir, `${profileName}.cpuprofile`);
      fs.writeFileSync(outputFile, JSON.stringify(profile));
      
      console.log(`CPU profile saved to: ${outputFile}`);
      profile.delete();
      this.isProfiling = false;
    }, duration);
  }
  
  // メモリヒープスナップショットを取得
  public takeHeapSnapshot(): void {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const snapshotName = `heap-snapshot-${timestamp}`;
    
    console.log(`Taking heap snapshot: ${snapshotName}`);
    
    const snapshot = v8Profiler.takeSnapshot(snapshotName);
    
    const outputDir = path.join(process.cwd(), 'profiles');
    if (!fs.existsSync(outputDir)) {
      fs.mkdirSync(outputDir, { recursive: true });
    }
    
    const outputFile = path.join(outputDir, `${snapshotName}.heapsnapshot`);
    snapshot.export()
      .pipe(fs.createWriteStream(outputFile))
      .on('finish', () => {
        console.log(`Heap snapshot saved to: ${outputFile}`);
        snapshot.delete();
      });
  }
}

// 使用例
export const startProfiler = () => {
  if (process.env.NODE_ENV === 'production' && process.env.ENABLE_PROFILING === 'true') {
    const profiler = Profiler.getInstance();
    
    // アプリケーション起動時にCPUプロファイリングを開始
    profiler.startCPUProfiling(60000); // 60秒間プロファイリング
    
    // 定期的にヒープスナップショットを取得(メモリリーク検出用)
    setInterval(() => {
      profiler.takeHeapSnapshot();
    }, 3600000); // 1時間ごと
  }
};

これらのテクニックを組み合わせることで、本番環境と同じ条件でのデバッグが可能になり、開発環境では発見できなかった問題を特定して解決することができます。これにより、アプリケーションの安定性と信頼性が向上し、本番環境でのトラブルを未然に防ぐことができます。

TH

Tasuke Hub管理人

東証プライム市場上場企業エンジニア

情報系修士卒業後、大手IT企業にてフルスタックエンジニアとして活躍。 Webアプリケーション開発からクラウドインフラ構築まで幅広い技術に精通し、 複数のプロジェクトでリードエンジニアを担当。 技術ブログやオープンソースへの貢献を通じて、日本のIT技術コミュニティに積極的に関わっている。

🎓情報系修士🏢東証プライム上場企業💻フルスタックエンジニア📝技術ブログ執筆者

このトピックはこちらの書籍で勉強するのがおすすめ!

この記事の内容をさらに深く理解したい方におすすめの一冊です。実践的な知識を身につけたい方は、ぜひチェックしてみてください!

おすすめ記事

おすすめコンテンツ