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. セキュリティの懸念
- 機密情報を含む添付ファイルのテキスト化によるリスク
- 検索可能になることでの情報漏洩リスクの増大
- アクセス権限の細かい制御が困難
今後使えそうな知見
-
段階的なロールアウト
- 機能フラグを使用して一部のユーザーのみに展開
- メトリクスを監視しながら徐々に拡大
-
非同期処理の設計
- ジョブキューの優先度設定を適切に行う
- バックプレッシャー機構の実装
-
ファイル処理の最適化
// ストリーミング処理の例 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つの観点から慎重に評価する必要があります。
失敗は成功の母とも言います。この経験を活かし、次回は以下のアプローチを取る予定です:
- 小さく始める: まずは特定のファイル形式のみ対応
- メトリクスファースト: 監視とアラートを先に整備
- コスト計算: 事前により詳細なコスト試算を実施
- 段階的展開: カナリアリリースで問題を早期発見
皆さんも似たような機能を実装する際は、ぜひこれらの点に注意してください。技術的な挑戦は楽しいですが、実運用での成功こそが最終的なゴールです。
この記事で紹介したコードは、実際のプロダクションコードを簡略化したものです。エラーハンドリングやセキュリティチェックなど、実際の実装では追加の考慮事項があります。
関連技術: Google Cloud Storage, Node.js, TypeScript, pdf-parse, mammoth, xlsx, Cloud Functions, BullMQ, コスト最適化, サーキットブレーカーパターン
筆者: 91works開発チーム
Discussion