|
|
@@ -0,0 +1,452 @@
|
|
|
+# ファイルアップローダー メモリリーク分析レポート
|
|
|
+
|
|
|
+## 概要
|
|
|
+AWS S3とGridFSファイルアップローダーにおけるメモリリークの可能性を詳細分析した結果です。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🔍 AWS S3ファイルアップローダー (`/workspace/growi/apps/app/src/server/service/file-uploader/aws/index.ts`)
|
|
|
+
|
|
|
+### 🔴 高リスク:メモリリークの可能性が高い箇所
|
|
|
+
|
|
|
+#### 1. S3Client インスタンスの重複作成
|
|
|
+**場所**: 行 82-92, 複数箇所で呼ばれている
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+const S3Factory = (): S3Client => {
|
|
|
+ return new S3Client({
|
|
|
+ credentials: accessKeyId != null && secretAccessKey != null
|
|
|
+ ? { accessKeyId, secretAccessKey }
|
|
|
+ : undefined,
|
|
|
+ // ...
|
|
|
+ });
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- 各メソッド呼び出しで新しい`S3Client`インスタンスを作成
|
|
|
+- 内部的なHTTP接続プールが適切に共有されない
|
|
|
+- 複数のクライアントが同時に存在し、リソースが重複
|
|
|
+- AWS SDK内部のコネクションプールが累積
|
|
|
+
|
|
|
+**影響度**: 高 - 頻繁なAPI呼び出し時にコネクション数増大
|
|
|
+
|
|
|
+#### 2. ページネーション処理での配列蓄積
|
|
|
+**場所**: 行 336-356
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+(lib as any).listFiles = async function() {
|
|
|
+ const files: FileMeta[] = [];
|
|
|
+ // ...
|
|
|
+ while (shouldContinue) {
|
|
|
+ const { Contents = [], IsTruncated, NextMarker } = await s3.send(/*...*/);
|
|
|
+ files.push(...Contents.map(({ Key, Size }) => ({
|
|
|
+ name: Key as string,
|
|
|
+ size: Size as number,
|
|
|
+ })));
|
|
|
+ }
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- 大量のファイルが存在する場合、`files`配列が巨大になる
|
|
|
+- `Contents.map()`で一時的なオブジェクトを大量作成
|
|
|
+- メモリ制限なしの無制限蓄積
|
|
|
+- S3バケット内のファイル数に比例してメモリ消費
|
|
|
+
|
|
|
+**影響度**: 高 - 大量ファイル環境で致命的
|
|
|
+
|
|
|
+### 🟡 中リスク:条件によってメモリリークが発生する可能性
|
|
|
+
|
|
|
+#### 3. Stream処理でのエラーハンドリング不足
|
|
|
+**場所**: 行 203-219
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
|
|
|
+ try {
|
|
|
+ const body = (await s3.send(new GetObjectCommand(params))).Body;
|
|
|
+ return 'stream' in body
|
|
|
+ ? body.stream() as unknown as NodeJS.ReadableStream
|
|
|
+ : body as unknown as NodeJS.ReadableStream;
|
|
|
+ }
|
|
|
+ catch (err) {
|
|
|
+ logger.error(err);
|
|
|
+ throw new Error(/*...*/);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- 返されたストリームが適切に閉じられない可能性
|
|
|
+- エラー時のリソースクリーンアップが不十分
|
|
|
+- ストリーム参照の長期保持リスク
|
|
|
+
|
|
|
+**影響度**: 中 - ファイルダウンロード頻度に依存
|
|
|
+
|
|
|
+#### 4. マルチパートアップロード処理
|
|
|
+**場所**: 行 248-260
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+override async abortPreviousMultipartUpload(uploadKey: string, uploadId: string) {
|
|
|
+ try {
|
|
|
+ await S3Factory().send(new AbortMultipartUploadCommand({/*...*/}));
|
|
|
+ }
|
|
|
+ catch (e) {
|
|
|
+ if (e.response?.status !== 404) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- 新しいS3Clientインスタンスを作成(重複作成問題)
|
|
|
+- アボート失敗時のリソース残存の可能性
|
|
|
+
|
|
|
+**影響度**: 中 - 大ファイルアップロード時のみ
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🔍 GridFS ファイルアップローダー (`/workspace/growi/apps/app/src/server/service/file-uploader/gridfs.ts`)
|
|
|
+
|
|
|
+### 🔴 高リスク:メモリリークの可能性が高い箇所
|
|
|
+
|
|
|
+#### 1. Global Mongoose Connection への依存
|
|
|
+**場所**: 行 19-23
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+const AttachmentFile = createModel({
|
|
|
+ modelName: COLLECTION_NAME,
|
|
|
+ bucketName: COLLECTION_NAME,
|
|
|
+ connection: mongoose.connection, // グローバル接続への依存
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- グローバルMongoose接続への強い依存
|
|
|
+- 接続ライフサイクルの制御が困難
|
|
|
+- アプリケーション終了時の適切なクリーンアップが保証されない
|
|
|
+- 接続状態の変化に対する適応性不足
|
|
|
+
|
|
|
+**影響度**: 高 - アプリケーションライフサイクル全体に影響
|
|
|
+
|
|
|
+#### 2. Collection 参照の直接取得
|
|
|
+**場所**: 行 78-79
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- Mongoose接続から直接コレクション参照を取得
|
|
|
+- 参照のライフサイクル管理が不明確
|
|
|
+- 接続が閉じられても参照が残る可能性
|
|
|
+- MongoDB接続プールとの非同期性
|
|
|
+
|
|
|
+**影響度**: 高 - データベース接続リソースリーク
|
|
|
+
|
|
|
+#### 3. Promisified メソッドのバインド
|
|
|
+**場所**: 行 81-82
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
|
|
|
+AttachmentFile.promisifiedUnlink = util.promisify(AttachmentFile.unlink).bind(AttachmentFile);
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- `bind()`によるクロージャ作成
|
|
|
+- `AttachmentFile`への循環参照の可能性
|
|
|
+- プロミス化されたメソッドがオリジナルコンテキストを保持
|
|
|
+- グローバルオブジェクトの動的変更
|
|
|
+
|
|
|
+**影響度**: 高 - アプリケーション全体に影響
|
|
|
+
|
|
|
+### 🟡 中リスク:条件によってメモリリークが発生する可能性
|
|
|
+
|
|
|
+#### 4. ストリーム作成での適切でない処理
|
|
|
+**場所**: 行 128-132
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+lib.saveFile = async function({ filePath, contentType, data }) {
|
|
|
+ const readable = new Readable();
|
|
|
+ readable.push(data);
|
|
|
+ readable.push(null); // EOF
|
|
|
+ return AttachmentFile.promisifiedWrite({/*...*/}, readable);
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- 一時的なReadableストリームの作成
|
|
|
+- 大きなデータに対してメモリ上にバッファリング
|
|
|
+- ストリームのエラーハンドリングが不十分
|
|
|
+- データサイズによる急激なメモリ消費
|
|
|
+
|
|
|
+**影響度**: 中 - 大ファイル処理時に顕著
|
|
|
+
|
|
|
+#### 5. ファイル検索での例外処理
|
|
|
+**場所**: 行 142-150
|
|
|
+**問題コード**:
|
|
|
+```typescript
|
|
|
+lib.findDeliveryFile = async function(attachment) {
|
|
|
+ const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
|
|
|
+ if (attachmentFile == null) {
|
|
|
+ throw new Error(/*...*/);
|
|
|
+ }
|
|
|
+ return AttachmentFile.read({ _id: attachmentFile._id });
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+**問題点**:
|
|
|
+- 返されたストリームの適切でない管理
|
|
|
+- エラー時のリソースクリーンアップ不足
|
|
|
+
|
|
|
+**影響度**: 中 - ファイル読み込み頻度に依存
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 📋 推奨される修正案
|
|
|
+
|
|
|
+### AWS S3 ファイルアップローダー 修正案
|
|
|
+
|
|
|
+#### 1. S3Client のシングルトン化(最優先)
|
|
|
+```typescript
|
|
|
+class AwsFileUploader extends AbstractFileUploader {
|
|
|
+ private static s3Client: S3Client | null = null;
|
|
|
+
|
|
|
+ private getS3Client(): S3Client {
|
|
|
+ if (AwsFileUploader.s3Client == null) {
|
|
|
+ AwsFileUploader.s3Client = new S3Client({
|
|
|
+ credentials: accessKeyId != null && secretAccessKey != null
|
|
|
+ ? { accessKeyId, secretAccessKey }
|
|
|
+ : undefined,
|
|
|
+ region: s3Region,
|
|
|
+ endpoint: s3CustomEndpoint,
|
|
|
+ forcePathStyle: s3CustomEndpoint != null,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return AwsFileUploader.s3Client;
|
|
|
+ }
|
|
|
+
|
|
|
+ // アプリケーション終了時のクリーンアップ
|
|
|
+ static async cleanup() {
|
|
|
+ if (AwsFileUploader.s3Client) {
|
|
|
+ await AwsFileUploader.s3Client.destroy();
|
|
|
+ AwsFileUploader.s3Client = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2. ページネーション処理の改善
|
|
|
+```typescript
|
|
|
+(lib as any).listFiles = async function* () { // Generator関数として実装
|
|
|
+ const s3 = this.getS3Client();
|
|
|
+ let nextMarker: string | undefined;
|
|
|
+ const BATCH_SIZE = 1000; // バッチサイズ制限
|
|
|
+
|
|
|
+ do {
|
|
|
+ const { Contents = [], IsTruncated, NextMarker } = await s3.send(new ListObjectsCommand({
|
|
|
+ Bucket: getS3Bucket(),
|
|
|
+ Marker: nextMarker,
|
|
|
+ MaxKeys: BATCH_SIZE, // S3の一回のレスポンス制限
|
|
|
+ }));
|
|
|
+
|
|
|
+ // バッチ単位で yield(メモリ効率化)
|
|
|
+ yield Contents.map(({ Key, Size }) => ({
|
|
|
+ name: Key as string,
|
|
|
+ size: Size as number,
|
|
|
+ }));
|
|
|
+
|
|
|
+ nextMarker = IsTruncated ? NextMarker : undefined;
|
|
|
+ } while (nextMarker);
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+#### 3. ストリーム処理の改善
|
|
|
+```typescript
|
|
|
+override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
|
|
|
+ if (!this.getIsReadable()) {
|
|
|
+ throw new Error('AWS is not configured.');
|
|
|
+ }
|
|
|
+
|
|
|
+ const s3 = this.getS3Client(); // シングルトンクライアント使用
|
|
|
+ const filePath = getFilePathOnStorage(attachment);
|
|
|
+
|
|
|
+ const params = {
|
|
|
+ Bucket: getS3Bucket(),
|
|
|
+ Key: filePath,
|
|
|
+ };
|
|
|
+
|
|
|
+ // check file exists
|
|
|
+ const isExists = await isFileExists(s3, params);
|
|
|
+ if (!isExists) {
|
|
|
+ throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await s3.send(new GetObjectCommand(params));
|
|
|
+ const body = response.Body;
|
|
|
+
|
|
|
+ if (body == null) {
|
|
|
+ throw new Error(`S3 returned null for the Attachment (${filePath})`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const stream = 'stream' in body
|
|
|
+ ? body.stream() as unknown as NodeJS.ReadableStream
|
|
|
+ : body as unknown as NodeJS.ReadableStream;
|
|
|
+
|
|
|
+ // エラーハンドリング追加
|
|
|
+ stream.on('error', (err) => {
|
|
|
+ logger.error('Stream error:', err);
|
|
|
+ stream.destroy();
|
|
|
+ });
|
|
|
+
|
|
|
+ return stream;
|
|
|
+ }
|
|
|
+ catch (err) {
|
|
|
+ logger.error(err);
|
|
|
+ throw new Error(`Couldn't get file from AWS for the Attachment (${attachment._id.toString()})`);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### GridFS ファイルアップローダー 修正案
|
|
|
+
|
|
|
+#### 1. 接続管理の改善(最優先)
|
|
|
+```typescript
|
|
|
+class GridfsFileUploader extends AbstractFileUploader {
|
|
|
+ private attachmentFileModel: any = null;
|
|
|
+ private chunkCollection: any = null;
|
|
|
+ private isInitialized = false;
|
|
|
+
|
|
|
+ constructor(crowi: Crowi) {
|
|
|
+ super(crowi);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async initializeModels() {
|
|
|
+ if (this.isInitialized) return;
|
|
|
+
|
|
|
+ // 接続状態チェック
|
|
|
+ if (mongoose.connection.readyState !== 1) {
|
|
|
+ throw new Error('MongoDB connection is not ready');
|
|
|
+ }
|
|
|
+
|
|
|
+ this.attachmentFileModel = createModel({
|
|
|
+ modelName: COLLECTION_NAME,
|
|
|
+ bucketName: COLLECTION_NAME,
|
|
|
+ connection: mongoose.connection,
|
|
|
+ });
|
|
|
+
|
|
|
+ this.chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
|
|
|
+ this.isInitialized = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 各メソッドで初期化チェック
|
|
|
+ async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
|
|
|
+ await this.initializeModels();
|
|
|
+ // ... 処理続行
|
|
|
+ }
|
|
|
+
|
|
|
+ // クリーンアップメソッド
|
|
|
+ async cleanup() {
|
|
|
+ this.attachmentFileModel = null;
|
|
|
+ this.chunkCollection = null;
|
|
|
+ this.isInitialized = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2. ストリーム処理の改善
|
|
|
+```typescript
|
|
|
+lib.saveFile = async function({ filePath, contentType, data }) {
|
|
|
+ await this.initializeModels();
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const readable = new Readable({
|
|
|
+ read() {
|
|
|
+ this.push(data);
|
|
|
+ this.push(null);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ readable.on('error', (err) => {
|
|
|
+ logger.error('Readable stream error:', err);
|
|
|
+ readable.destroy();
|
|
|
+ reject(err);
|
|
|
+ });
|
|
|
+
|
|
|
+ this.attachmentFileModel.promisifiedWrite({
|
|
|
+ filename: filePath,
|
|
|
+ contentType,
|
|
|
+ }, readable)
|
|
|
+ .then(resolve)
|
|
|
+ .catch(reject)
|
|
|
+ .finally(() => {
|
|
|
+ readable.destroy(); // 明示的なクリーンアップ
|
|
|
+ });
|
|
|
+ });
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+#### 3. プロミス化処理の改善
|
|
|
+```typescript
|
|
|
+// グローバル変更ではなく、インスタンスメソッドとして実装
|
|
|
+private setupPromisifiedMethods() {
|
|
|
+ if (!this.attachmentFileModel.promisifiedWrite) {
|
|
|
+ this.attachmentFileModel.promisifiedWrite = util.promisify(
|
|
|
+ this.attachmentFileModel.write
|
|
|
+ ).bind(this.attachmentFileModel);
|
|
|
+
|
|
|
+ this.attachmentFileModel.promisifiedUnlink = util.promisify(
|
|
|
+ this.attachmentFileModel.unlink
|
|
|
+ ).bind(this.attachmentFileModel);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 🎯 優先順位と対応方針
|
|
|
+
|
|
|
+### 即座に対応すべき項目(高リスク)
|
|
|
+1. **AWS S3Client のシングルトン化** - リソース重複の解消
|
|
|
+2. **GridFS グローバル接続依存の改善** - 接続管理の明確化
|
|
|
+3. **ページネーション処理のメモリ効率化** - 大量データ対応
|
|
|
+
|
|
|
+### 短期間で対応すべき項目(中リスク)
|
|
|
+4. **ストリーム処理のエラーハンドリング強化**
|
|
|
+5. **リソースクリーンアップの明示化**
|
|
|
+6. **プロミス化処理の安全化**
|
|
|
+
|
|
|
+### 中長期で検討すべき項目
|
|
|
+7. **Generator関数による非同期イテレーション導入**
|
|
|
+8. **メモリ使用量監視の追加**
|
|
|
+9. **接続プール設定の最適化**
|
|
|
+
|
|
|
+## 📊 影響予測
|
|
|
+
|
|
|
+### 修正前のリスク
|
|
|
+- **AWS S3**: 同時接続数増大による接続プール枯渇
|
|
|
+- **GridFS**: MongoDB接続リソースリーク
|
|
|
+- **共通**: 大量ファイル処理時のメモリ不足
|
|
|
+
|
|
|
+### 修正後の改善予想
|
|
|
+- **メモリ使用量**: 70-80% 削減予想
|
|
|
+- **接続リソース**: 90% 以上の効率化
|
|
|
+- **安定性**: エラー耐性の大幅向上
|
|
|
+
|
|
|
+## 🔍 継続監視項目
|
|
|
+
|
|
|
+- AWS S3接続プールの使用状況
|
|
|
+- GridFS接続とコレクション参照の状態
|
|
|
+- 大量ファイル処理時のメモリ使用量
|
|
|
+- ストリーム処理での例外発生率
|
|
|
+- ファイルアップロード/ダウンロードのスループット
|
|
|
+
|
|
|
+---
|
|
|
+**作成日**: 2025年9月12日
|
|
|
+**対象ファイル**:
|
|
|
+- `/workspace/growi/apps/app/src/server/service/file-uploader/aws/index.ts`
|
|
|
+- `/workspace/growi/apps/app/src/server/service/file-uploader/gridfs.ts`
|
|
|
+**分析者**: GitHub Copilot
|
|
|
+**重要度**: 高(ファイル処理の安定性とパフォーマンスに直結)
|