🐡

GCS統合とテキスト抽出機能の実装と撤回から学んだこと

に公開

概要

メール管理システムにGoogle Cloud Storage(GCS)を統合し、添付ファイルからテキストを自動抽出する機能を実装しました。PDF、Word、Excelなど様々な形式に対応し、添付ファイルの内容まで検索可能にすることが目的でした。しかし、本番環境での運用開始後に想定外の問題が発生し、最終的にrevertすることになりました。この記事では、実装の技術的詳細と、なぜ撤回に至ったのかの教訓を共有します。

技術スタック

  • Google Cloud Storage: ファイルストレージ
  • Node.js / TypeScript: バックエンド実装
  • ライブラリ:
    • pdf-parse: PDF解析
    • mammoth: Word文書(.docx)処理
    • xlsx: Excel処理
    • chardet / iconv-lite: 文字エンコーディング検出・変換

実装内容

1. ストレージサービスの抽象化

まず、ローカルファイルシステムとGCSを切り替え可能にするため、ストレージサービスを抽象化しました:

// storage-service.ts
interface StorageService {
  saveFile(
    path: string,
    content: Buffer,
    metadata?: Record<string, string>
  ): Promise<void>;

  getFile(path: string): Promise<Buffer>;
  deleteFile(path: string): Promise<void>;
  listFiles(prefix: string): Promise<string[]>;
}

// ローカルストレージ実装
class LocalStorageService implements StorageService {
  private baseDir: string;

  async saveFile(path: string, content: Buffer) {
    const fullPath = path.join(this.baseDir, path);
    await fs.promises.writeFile(fullPath, content);
  }
  // ... その他のメソッド
}

// GCSストレージ実装
class GCSStorageService implements StorageService {
  private bucket: Bucket;

  async saveFile(
    path: string,
    content: Buffer,
    metadata?: Record<string, string>
  ) {
    const file = this.bucket.file(path);
    await file.save(content, {
      metadata: {
        contentType: this.detectContentType(path),
        ...metadata,
      },
    });
  }
  // ... その他のメソッド
}

2. テキスト抽出サービスの実装

様々なファイル形式からテキストを抽出するサービスを実装:

// text-extractor.ts
class TextExtractorService {
  async extractText(buffer: Buffer, mimeType: string): Promise<string> {
    try {
      switch (mimeType) {
        case 'application/pdf':
          return await this.extractFromPDF(buffer);

        case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
          return await this.extractFromDOCX(buffer);

        case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
          return await this.extractFromXLSX(buffer);

        case 'text/plain':
        case 'text/csv':
          return await this.extractFromText(buffer);

        default:
          return '';
      }
    } catch (error) {
      console.error(`Text extraction failed for ${mimeType}:`, error);
      return '';
    }
  }

  private async extractFromPDF(buffer: Buffer): Promise<string> {
    const data = await pdfParse(buffer);
    return data.text;
  }

  private async extractFromText(buffer: Buffer): Promise<string> {
    // 日本語エンコーディングを考慮
    const encoding = chardet.detect(buffer);

    if (encoding === 'Shift_JIS' || encoding === 'EUC-JP') {
      return iconv.decode(buffer, encoding);
    }

    return buffer.toString('utf-8');
  }
}

3. バッチ処理による既存ファイルの処理

既にアップロード済みのファイルを一括処理するバッチ処理を実装:

// batch-text-processor.ts
class BatchTextProcessor {
  private processedCount = 0;
  private errorCount = 0;
  private queue: Queue<TextExtractionJob>;

  async processAllAttachments(): Promise<void> {
    // 処理対象のファイルを取得
    const attachments = await this.getUnprocessedAttachments();

    console.log(`Processing ${attachments.length} attachments...`);

    // ジョブキューに追加
    const jobs = attachments.map(attachment => ({
      id: attachment.id,
      gcsPath: attachment.filePath,
      mimeType: attachment.mimeType,
    }));

    await this.queue.addBulk(
      jobs.map(job => ({
        name: 'extract-text',
        data: job,
      }))
    );

    // ワーカーでの処理を待機
    await this.waitForCompletion();
  }

  private async processJob(job: Job<TextExtractionJob>): Promise<void> {
    const { id, gcsPath, mimeType } = job.data;

    try {
      // GCSからファイルを取得
      const buffer = await this.storage.getFile(gcsPath);

      // テキスト抽出
      const extractedText = await this.textExtractor.extractText(
        buffer,
        mimeType
      );

      // データベース更新
      await this.updateAttachment(id, {
        extractedText,
        extractionStatus: 'completed',
        extractedAt: new Date(),
      });

      this.processedCount++;
    } catch (error) {
      console.error(`Failed to process attachment ${id}:`, error);

      await this.updateAttachment(id, {
        extractionStatus: 'failed',
        extractionError: error.message,
      });

      this.errorCount++;
    }
  }
}

4. 検索機能の拡張

添付ファイルの内容も検索対象に含めるよう検索機能を拡張:

// email-repository.ts
async searchEmails(query: string, options: SearchOptions) {
  const searchConditions = {
    OR: [
      // 既存のメール本文検索
      { subject: { contains: query, mode: 'insensitive' } },
      { bodyText: { contains: query, mode: 'insensitive' } },

      // 添付ファイル内容の検索を追加
      {
        attachments: {
          some: {
            extractedText: {
              contains: query,
              mode: 'insensitive'
            }
          }
        }
      }
    ]
  };

  return await prisma.email.findMany({
    where: searchConditions,
    include: {
      attachments: {
        select: {
          filename: true,
          extractedText: true,
          mimeType: true,
        }
      }
    },
    ...options
  });
}

学んだこと

意外だった落とし穴

1. 文字エンコーディングの複雑性

日本語のビジネス文書では、UTF-8以外にもShift_JISやEUC-JPが未だに使用されていることが判明。特に古いExcelファイルやCSVファイルで問題が発生しました。

2. メモリ使用量の急増

大きなPDFファイル(100MB以上)を処理する際、pdf-parseライブラリがファイル全体をメモリに展開するため、複数のファイルを並行処理するとメモリ不足に陥りました。

3. 処理時間の見積もりミス

画像が多く含まれるPDFやプレゼンテーション資料の処理に想定以上の時間がかかり、キューがつまることがありました。

なぜrevertしたのか

1. パフォーマンスの問題

  • メール受信の遅延: 平均処理時間が500ms → 3秒に増加
  • メモリ使用量: 100MB超のPDFファイルでメモリ使用量が2GB超に到達
  • データベース負荷: 同時接続数が平常時の3倍(30 → 90接続)に増加
  • レスポンスタイム: 検索クエリの実行時間が1.5秒 → 8秒に悪化

2. コストの問題

  • GCSストレージコスト:
    • 月間想定: ¥5,000 → 実績: ¥32,000(6.4倍)
    • 1ファイルあたり平均5MBで、月3,000ファイル追加
  • データ転送コスト:
    • GCS → Cloud Functions間の転送: 月¥12,000
    • 予想外のイグレス料金が発生
  • Cloud Functions実行コスト:
    • 月間想定: ¥8,000 → 実績: ¥28,000(3.5倍)
    • 大きなPDFファイル(50MB+)の処理時間: 平均45秒
  • 総コスト: 月¥13,000の想定が¥72,000に増大(5.5倍)

3. セキュリティの懸念

  • 機密情報を含む添付ファイルのテキスト化によるリスク
  • 検索可能になることでの情報漏洩リスクの増大
  • アクセス権限の細かい制御が困難

今後使えそうな知見

  1. 段階的なロールアウト

    • 機能フラグを使用して一部のユーザーのみに展開
    • メトリクスを監視しながら徐々に拡大
  2. 非同期処理の設計

    • ジョブキューの優先度設定を適切に行う
    • バックプレッシャー機構の実装
  3. ファイル処理の最適化

    // ストリーミング処理の例
    async processLargeFile(stream: ReadableStream) {
      const chunks: string[] = [];
    
      for await (const chunk of stream) {
        const text = await this.extractChunk(chunk);
        chunks.push(text);
    
        // メモリ使用量を制限
        if (chunks.length > MAX_CHUNKS) {
          await this.flushToDatabase(chunks);
          chunks.length = 0;
        }
      }
    }
    

もっと良い書き方の発見

1. コスト監視の実装

運用開始前にコスト監視を実装すべきでした:

// cost-monitor.ts
interface CostMetrics {
  storageGB: number;
  transferGB: number;
  functionExecutionMs: number;
  estimatedCost: number;
}

class CostMonitor {
  private readonly DAILY_BUDGET = 2400; // ¥72,000 / 30日
  private readonly ALERT_THRESHOLD = 0.8; // 80%で警告

  async getCurrentDailyCost(): Promise<number> {
    const metrics = await this.getMetrics();
    return this.calculateCost(metrics);
  }

  async checkBudget(): Promise<{
    withinBudget: boolean;
    usage: number;
    remaining: number;
  }> {
    const currentCost = await this.getCurrentDailyCost();
    const threshold = this.DAILY_BUDGET * this.ALERT_THRESHOLD;

    if (currentCost > threshold) {
      await this.sendAlert(
        `Daily budget ${Math.round((currentCost / this.DAILY_BUDGET) * 100)}% consumed`
      );
    }

    return {
      withinBudget: currentCost < this.DAILY_BUDGET,
      usage: currentCost,
      remaining: this.DAILY_BUDGET - currentCost,
    };
  }

  private calculateCost(metrics: CostMetrics): number {
    // GCS Storage: $0.020 per GB/month
    const storageCost = metrics.storageGB * 0.020 * 150; // JPY

    // GCS Egress (Asia): $0.12 per GB
    const transferCost = metrics.transferGB * 0.12 * 150; // JPY

    // Cloud Functions: $0.40 per million invocations
    // + $0.0000025 per GB-second
    const functionCost = (metrics.functionExecutionMs / 1000) * 0.0000025 * 150;

    return storageCost + transferCost + functionCost;
  }

  private async sendAlert(message: string): Promise<void> {
    // Slack、メール、Cloud Monitoringなどに通知
    console.error(`[COST ALERT] ${message}`);
  }
}

2. 選択的テキスト抽出の実装

すべてのファイルではなく、必要なファイルのみ処理する:

// selective-text-extractor.ts
interface ExtractOptions {
  maxFileSize: number;        // 最大ファイルサイズ(MB)
  allowedMimeTypes: string[]; // 許可するMIMEタイプ
  priority: 'high' | 'medium' | 'low';
  confidential?: boolean;     // 機密ファイルかどうか
}

class SelectiveTextExtractor {
  private readonly DEFAULT_OPTIONS: ExtractOptions = {
    maxFileSize: 10, // 10MB
    allowedMimeTypes: [
      'application/pdf',
      'text/plain',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    ],
    priority: 'medium',
  };

  async shouldExtractText(
    file: Attachment,
    options: Partial<ExtractOptions> = {}
  ): Promise<boolean> {
    const opts = { ...this.DEFAULT_OPTIONS, ...options };

    // ファイルサイズチェック
    const fileSizeMB = file.size / (1024 * 1024);
    if (fileSizeMB > opts.maxFileSize) {
      console.log(
        `Skipping ${file.filename}: size ${fileSizeMB}MB exceeds limit`
      );
      return false;
    }

    // MIMEタイプチェック
    if (!opts.allowedMimeTypes.includes(file.mimeType)) {
      console.log(
        `Skipping ${file.filename}: MIME type ${file.mimeType} not allowed`
      );
      return false;
    }

    // 機密ファイルは高優先度のみ処理
    if (file.confidential && opts.priority !== 'high') {
      console.log(`Skipping confidential file: ${file.filename}`);
      return false;
    }

    // コスト予算チェック
    const costMonitor = new CostMonitor();
    const { withinBudget } = await costMonitor.checkBudget();

    if (!withinBudget && opts.priority !== 'high') {
      console.log('Daily budget exceeded, skipping non-high priority files');
      return false;
    }

    return true;
  }

  async extractWithLimits(
    file: Attachment,
    options?: Partial<ExtractOptions>
  ): Promise<string | null> {
    if (!(await this.shouldExtractText(file, options))) {
      return null;
    }

    try {
      const buffer = await this.storage.getFile(file.filePath);
      return await this.textExtractor.extractText(buffer, file.mimeType);
    } catch (error) {
      console.error(`Failed to extract text from ${file.filename}:`, error);
      return null;
    }
  }
}

3. プログレッシブエンハンスメント

まずは基本的なメタデータ検索から始め、段階的にフルテキスト検索を追加:

class SearchService {
  async search(query: string, options: SearchOptions) {
    // Level 1: メタデータ検索(高速)
    const metadataResults = await this.searchMetadata(query);

    if (metadataResults.length >= options.limit) {
      return metadataResults;
    }

    // Level 2: 本文検索(中速)
    const bodyResults = await this.searchBody(query);

    if (!options.includeAttachments) {
      return [...metadataResults, ...bodyResults];
    }

    // Level 3: 添付ファイル検索(低速)
    const attachmentResults = await this.searchAttachments(query);

    return this.mergeResults(
      metadataResults,
      bodyResults,
      attachmentResults
    );
  }
}

4. ロールバック手順の明確化

実装のrevert時の手順を記録しておくべきでした:

// rollback-procedure.ts
class RollbackManager {
  /**
   * GCS統合機能の段階的なロールバック
   */
  async rollbackGCSIntegration(): Promise<void> {
    console.log('Starting GCS integration rollback...');

    // Step 1: 新規ファイルのGCSアップロードを停止
    await this.disableGCSUpload();

    // Step 2: テキスト抽出バッチ処理を停止
    await this.stopBatchProcessing();

    // Step 3: 検索クエリから添付ファイル検索を除外
    await this.disableAttachmentSearch();

    // Step 4: データベースの整合性を確認
    await this.verifyDatabaseIntegrity();

    // Step 5: GCSに保存されたファイルをローカルストレージに移行
    await this.migrateFilesFromGCS();

    // Step 6: GCSバケットをクリーンアップ(オプション)
    // await this.cleanupGCSBucket();

    console.log('Rollback completed successfully');
  }

  private async verifyDatabaseIntegrity(): Promise<void> {
    // 添付ファイルのレコードとファイルの存在を確認
    const attachments = await prisma.attachment.findMany({
      where: {
        filePath: { startsWith: 'gs://' },
      },
    });

    console.log(`Verifying ${attachments.length} attachments...`);

    let missingCount = 0;
    for (const attachment of attachments) {
      const exists = await this.storage.fileExists(attachment.filePath);
      if (!exists) {
        console.error(`Missing file: ${attachment.filePath}`);
        missingCount++;
      }
    }

    if (missingCount > 0) {
      throw new Error(
        `${missingCount} files are missing. Manual intervention required.`
      );
    }
  }
}

5. サーキットブレーカーパターン

外部サービスの障害時に全体が停止しないよう保護:

class CircuitBreaker {
  private failures = 0;
  private lastFailTime?: Date;
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';

  async execute<T>(fn: () => Promise<T>): Promise<T | null> {
    if (this.state === 'OPEN') {
      if (this.shouldAttemptReset()) {
        this.state = 'HALF_OPEN';
      } else {
        return null; // 速やかに失敗
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
}

終わりに

今回の実装と撤回から、「技術的に可能」と「実運用で有用」は別物だということを改めて学びました。特に、既存システムに大きな機能を追加する際は、パフォーマンス、コスト、セキュリティの3つの観点から慎重に評価する必要があります。

失敗は成功の母とも言います。この経験を活かし、次回は以下のアプローチを取る予定です:

  1. 小さく始める: まずは特定のファイル形式のみ対応
  2. メトリクスファースト: 監視とアラートを先に整備
  3. コスト計算: 事前により詳細なコスト試算を実施
  4. 段階的展開: カナリアリリースで問題を早期発見

皆さんも似たような機能を実装する際は、ぜひこれらの点に注意してください。技術的な挑戦は楽しいですが、実運用での成功こそが最終的なゴールです。


この記事で紹介したコードは、実際のプロダクションコードを簡略化したものです。エラーハンドリングやセキュリティチェックなど、実際の実装では追加の考慮事項があります。

関連技術: Google Cloud Storage, Node.js, TypeScript, pdf-parse, mammoth, xlsx, Cloud Functions, BullMQ, コスト最適化, サーキットブレーカーパターン

筆者: 91works開発チーム

91works Tech Blog

Discussion