|
@@ -47,50 +47,8 @@ const sasToken = await (async() => {
|
|
|
|
|
|
|
|
**影響度**: 高 - URL生成処理の度に重複リソース消費
|
|
**影響度**: 高 - URL生成処理の度に重複リソース消費
|
|
|
|
|
|
|
|
-### 3. ReadableStream のライフサイクル管理不足
|
|
|
|
|
-**場所**: `findDeliveryFile`メソッド(行 164-182)
|
|
|
|
|
-**問題コード**:
|
|
|
|
|
-```typescript
|
|
|
|
|
-const downloadResponse = await blobClient.download();
|
|
|
|
|
-if (!downloadResponse?.readableStreamBody) {
|
|
|
|
|
- throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-return downloadResponse.readableStreamBody;
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**問題点**:
|
|
|
|
|
-- 返されたストリームの呼び出し元での適切な終了を保証する仕組みなし
|
|
|
|
|
-- `downloadResponse`オブジェクト自体がメタデータを保持し続ける可能性
|
|
|
|
|
-- Azure接続がストリーム終了まで保持され続ける
|
|
|
|
|
-- ストリーム読み取り中断時のリソースクリーンアップ不足
|
|
|
|
|
-- 大きなファイルダウンロード時の部分読み取り失敗でのリーク
|
|
|
|
|
-
|
|
|
|
|
-**影響度**: 高 - ファイルダウンロード処理でのリスク
|
|
|
|
|
-
|
|
|
|
|
-## 🟡 中リスク:条件によってメモリリークが発生する可能性
|
|
|
|
|
-
|
|
|
|
|
-### 4. uploadStream でのストリーム処理
|
|
|
|
|
-**場所**: `uploadAttachment`メソッド(行 125-143)
|
|
|
|
|
-**問題コード**:
|
|
|
|
|
-```typescript
|
|
|
|
|
-await blockBlobClient.uploadStream(readable, undefined, undefined, {
|
|
|
|
|
- blobHTTPHeaders: {
|
|
|
|
|
- blobContentType: contentHeaders.contentType?.value.toString(),
|
|
|
|
|
- blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
|
|
|
|
|
- },
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**問題点**:
|
|
|
|
|
-- `uploadStream`内部での中間バッファリング
|
|
|
|
|
-- アップロード失敗時のストリーム状態の不確定性
|
|
|
|
|
-- Azure SDK内部でのチャンクバッファリングによる一時的メモリ増大
|
|
|
|
|
-- 大きなファイルアップロード時の並列チャンク処理でのメモリ圧迫
|
|
|
|
|
|
|
|
|
|
-**影響度**: 中 - 大容量ファイルアップロード時に顕著
|
|
|
|
|
-
|
|
|
|
|
-### 5. 認証クレデンシャルの繰り返し作成
|
|
|
|
|
|
|
+### 3. 認証クレデンシャルの繰り返し作成
|
|
|
**場所**: `getCredential()` 関数(行 62-72)
|
|
**場所**: `getCredential()` 関数(行 62-72)
|
|
|
**問題コード**:
|
|
**問題コード**:
|
|
|
```typescript
|
|
```typescript
|
|
@@ -109,323 +67,12 @@ function getCredential(): TokenCredential {
|
|
|
- OAuthトークンの取得処理が重複実行
|
|
- OAuthトークンの取得処理が重複実行
|
|
|
- 認証状態の管理が非効率
|
|
- 認証状態の管理が非効率
|
|
|
|
|
|
|
|
-**影響度**: 中 - 認証処理の頻度に依存
|
|
|
|
|
-
|
|
|
|
|
-### 6. ContentHeaders オブジェクトの頻繁な作成
|
|
|
|
|
-**場所**: 複数箇所(uploadAttachment, generateTemporaryUrl)
|
|
|
|
|
-**問題コード**:
|
|
|
|
|
-```typescript
|
|
|
|
|
-const contentHeaders = new ContentHeaders(attachment);
|
|
|
|
|
-const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**問題点**:
|
|
|
|
|
-- 各リクエストで新しいContentHeadersインスタンスを作成
|
|
|
|
|
-- ヘッダー情報の解析処理が重複実行
|
|
|
|
|
-- 一時的なオブジェクト生成によるGC圧迫
|
|
|
|
|
-
|
|
|
|
|
-**影響度**: 中 - リクエスト数に比例した影響
|
|
|
|
|
-
|
|
|
|
|
-## 🟢 低リスク:潜在的なメモリリーク
|
|
|
|
|
-
|
|
|
|
|
-### 7. URL構築での文字列操作
|
|
|
|
|
-**場所**: `generateTemporaryUrl`メソッド内
|
|
|
|
|
-**問題コード**:
|
|
|
|
|
-```typescript
|
|
|
|
|
-const url = await (async() => {
|
|
|
|
|
- const containerClient = await getContainerClient();
|
|
|
|
|
- const filePath = getFilePathOnStorage(attachment);
|
|
|
|
|
- const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
|
|
|
|
|
- return blockBlobClient.url;
|
|
|
|
|
-})();
|
|
|
|
|
-
|
|
|
|
|
-const signedUrl = `${url}?${sasToken}`;
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**問題点**:
|
|
|
|
|
-- URL文字列の重複作成
|
|
|
|
|
-- 一時的な文字列オブジェクトの蓄積
|
|
|
|
|
-- 大量のURL生成時の文字列断片化
|
|
|
|
|
-
|
|
|
|
|
-**影響度**: 低 - 通常は自動的に解放
|
|
|
|
|
-
|
|
|
|
|
-### 8. 設定値の繰り返し取得
|
|
|
|
|
-**場所**: 複数箇所での`configManager.getConfig()`呼び出し
|
|
|
|
|
-**問題コード**:
|
|
|
|
|
-```typescript
|
|
|
|
|
-const lifetimeSecForTemporaryUrl = configManager.getConfig('azure:lifetimeSecForTemporaryUrl');
|
|
|
|
|
-const { accountName, containerName } = getAzureConfig();
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**問題点**:
|
|
|
|
|
-- 設定値の繰り返し取得・解析
|
|
|
|
|
-- キャッシュ機構がない場合の非効率な処理
|
|
|
|
|
-- 設定オブジェクトの一時的な蓄積
|
|
|
|
|
-
|
|
|
|
|
-**影響度**: 低 - 設定システムの実装に依存
|
|
|
|
|
-
|
|
|
|
|
-## 📋 推奨される修正案
|
|
|
|
|
-
|
|
|
|
|
-### 1. Azure クライアントのシングルトン化(最優先)
|
|
|
|
|
-```typescript
|
|
|
|
|
-class AzureClientManager {
|
|
|
|
|
- private static blobServiceClient: BlobServiceClient | null = null;
|
|
|
|
|
- private static credential: TokenCredential | null = null;
|
|
|
|
|
- private static cleanupTimeout: NodeJS.Timeout | null = null;
|
|
|
|
|
-
|
|
|
|
|
- static async getBlobServiceClient(): Promise<BlobServiceClient> {
|
|
|
|
|
- if (this.blobServiceClient == null) {
|
|
|
|
|
- const { accountName } = getAzureConfig();
|
|
|
|
|
- this.credential = this.getCredentialSingleton();
|
|
|
|
|
- this.blobServiceClient = new BlobServiceClient(
|
|
|
|
|
- `https://${accountName}.blob.core.windows.net`,
|
|
|
|
|
- this.credential
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // アイドル時のクリーンアップ設定
|
|
|
|
|
- this.resetCleanupTimer();
|
|
|
|
|
- return this.blobServiceClient;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- static getCredentialSingleton(): TokenCredential {
|
|
|
|
|
- if (this.credential == null) {
|
|
|
|
|
- const tenantId = toNonBlankStringOrUndefined(configManager.getConfig('azure:tenantId'));
|
|
|
|
|
- const clientId = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientId'));
|
|
|
|
|
- const clientSecret = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientSecret'));
|
|
|
|
|
|
|
+**対策**:
|
|
|
|
|
+- singleton インスタンスを作成
|
|
|
|
|
+- configManager.getConfig で取得する値に更新があればインスタンスを再作成
|
|
|
|
|
|
|
|
- if (tenantId == null || clientId == null || clientSecret == null) {
|
|
|
|
|
- throw new Error(`Azure Blob Storage missing required configuration`);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- this.credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
|
|
|
|
- }
|
|
|
|
|
- return this.credential;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- static async getContainerClient(): Promise<ContainerClient> {
|
|
|
|
|
- const { containerName } = getAzureConfig();
|
|
|
|
|
- const blobServiceClient = await this.getBlobServiceClient();
|
|
|
|
|
- return blobServiceClient.getContainerClient(containerName);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private static resetCleanupTimer(): void {
|
|
|
|
|
- if (this.cleanupTimeout) {
|
|
|
|
|
- clearTimeout(this.cleanupTimeout);
|
|
|
|
|
- }
|
|
|
|
|
- this.cleanupTimeout = setTimeout(() => {
|
|
|
|
|
- this.cleanup();
|
|
|
|
|
- }, 10 * 60 * 1000); // 10分後にクリーンアップ
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- static async cleanup(): Promise<void> {
|
|
|
|
|
- if (this.blobServiceClient) {
|
|
|
|
|
- try {
|
|
|
|
|
- // Azure SDK のクリーンアップ
|
|
|
|
|
- await this.blobServiceClient.pipeline.close?.();
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- logger.warn('Failed to close Azure blob service client:', e);
|
|
|
|
|
- }
|
|
|
|
|
- this.blobServiceClient = null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (this.credential && 'close' in this.credential) {
|
|
|
|
|
- try {
|
|
|
|
|
- await (this.credential as any).close?.();
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- logger.warn('Failed to close Azure credential:', e);
|
|
|
|
|
- }
|
|
|
|
|
- this.credential = null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (this.cleanupTimeout) {
|
|
|
|
|
- clearTimeout(this.cleanupTimeout);
|
|
|
|
|
- this.cleanupTimeout = null;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// プロセス終了時のクリーンアップ
|
|
|
|
|
-process.on('SIGTERM', () => AzureClientManager.cleanup());
|
|
|
|
|
-process.on('SIGINT', () => AzureClientManager.cleanup());
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 2. ストリーム処理の改善
|
|
|
|
|
-```typescript
|
|
|
|
|
-override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
|
|
|
|
|
- if (!this.getIsUploadable()) {
|
|
|
|
|
- throw new Error('Azure is not configured.');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- logger.debug(`File uploading: fileName=${attachment.fileName}`);
|
|
|
|
|
- const filePath = getFilePathOnStorage(attachment);
|
|
|
|
|
- const containerClient = await AzureClientManager.getContainerClient();
|
|
|
|
|
- const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
|
|
|
|
|
- const contentHeaders = new ContentHeaders(attachment);
|
|
|
|
|
-
|
|
|
|
|
- // ストリームのタイムアウト設定
|
|
|
|
|
- const timeoutPromise = new Promise((_, reject) => {
|
|
|
|
|
- setTimeout(() => reject(new Error('Upload timeout')), 5 * 60 * 1000); // 5分タイムアウト
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- await Promise.race([
|
|
|
|
|
- blockBlobClient.uploadStream(readable, undefined, undefined, {
|
|
|
|
|
- blobHTTPHeaders: {
|
|
|
|
|
- blobContentType: contentHeaders.contentType?.value.toString(),
|
|
|
|
|
- blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
|
|
|
|
|
- },
|
|
|
|
|
- maxConcurrency: 2, // 並列度制限
|
|
|
|
|
- maxSingleShotSize: 8 * 1024 * 1024, // 8MB制限
|
|
|
|
|
- }),
|
|
|
|
|
- timeoutPromise,
|
|
|
|
|
- ]);
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- // ストリームエラー時の明示的なクリーンアップ
|
|
|
|
|
- if (readable && typeof readable.destroy === 'function') {
|
|
|
|
|
- readable.destroy();
|
|
|
|
|
- }
|
|
|
|
|
- throw error;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 3. ReadableStream の適切な管理
|
|
|
|
|
-```typescript
|
|
|
|
|
-override async findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream> {
|
|
|
|
|
- if (!this.getIsReadable()) {
|
|
|
|
|
- throw new Error('Azure is not configured.');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const filePath = getFilePathOnStorage(attachment);
|
|
|
|
|
- const containerClient = await AzureClientManager.getContainerClient();
|
|
|
|
|
- const blobClient: BlobClient = containerClient.getBlobClient(filePath);
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const downloadResponse = await blobClient.download();
|
|
|
|
|
-
|
|
|
|
|
- if (downloadResponse.errorCode) {
|
|
|
|
|
- logger.error(downloadResponse.errorCode);
|
|
|
|
|
- throw new Error(downloadResponse.errorCode);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (!downloadResponse?.readableStreamBody) {
|
|
|
|
|
- throw new Error(`Coudn't get file from Azure for the Attachment (${filePath})`);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const stream = downloadResponse.readableStreamBody;
|
|
|
|
|
-
|
|
|
|
|
- // タイムアウト設定
|
|
|
|
|
- const timeout = setTimeout(() => {
|
|
|
|
|
- stream.destroy(new Error('Download stream timeout'));
|
|
|
|
|
- }, 10 * 60 * 1000); // 10分タイムアウト
|
|
|
|
|
-
|
|
|
|
|
- stream.on('end', () => clearTimeout(timeout));
|
|
|
|
|
- stream.on('error', () => clearTimeout(timeout));
|
|
|
|
|
- stream.on('close', () => clearTimeout(timeout));
|
|
|
|
|
-
|
|
|
|
|
- return stream;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- logger.error('Failed to create download stream:', error);
|
|
|
|
|
- throw new Error(`Coudn't get file from Azure for the Attachment (${attachment._id.toString()})`);
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 4. URL生成の最適化
|
|
|
|
|
-```typescript
|
|
|
|
|
-override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
|
|
|
|
|
- if (!this.getIsUploadable()) {
|
|
|
|
|
- throw new Error('Azure Blob is not configured.');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const lifetimeSecForTemporaryUrl = configManager.getConfig('azure:lifetimeSecForTemporaryUrl');
|
|
|
|
|
- const { accountName, containerName } = getAzureConfig();
|
|
|
|
|
- const filePath = getFilePathOnStorage(attachment);
|
|
|
|
|
-
|
|
|
|
|
- // 同一クライアントインスタンスを再利用
|
|
|
|
|
- const blobServiceClient = await AzureClientManager.getBlobServiceClient();
|
|
|
|
|
- const containerClient = await AzureClientManager.getContainerClient();
|
|
|
|
|
- const blockBlobClient = containerClient.getBlockBlobClient(filePath);
|
|
|
|
|
-
|
|
|
|
|
- const now = Date.now();
|
|
|
|
|
- const startsOn = new Date(now - 30 * 1000);
|
|
|
|
|
- const expiresOn = new Date(now + lifetimeSecForTemporaryUrl * 1000);
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
|
|
|
|
|
-
|
|
|
|
|
- const isDownload = opts?.download ?? false;
|
|
|
|
|
- const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
|
|
|
|
|
-
|
|
|
|
|
- const sasOptions = {
|
|
|
|
|
- containerName,
|
|
|
|
|
- permissions: ContainerSASPermissions.parse('rl'),
|
|
|
|
|
- protocol: SASProtocol.HttpsAndHttp,
|
|
|
|
|
- startsOn,
|
|
|
|
|
- expiresOn,
|
|
|
|
|
- contentType: contentHeaders.contentType?.value.toString(),
|
|
|
|
|
- contentDisposition: contentHeaders.contentDisposition?.value.toString(),
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
|
|
|
|
|
- const signedUrl = `${blockBlobClient.url}?${sasToken}`;
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- url: signedUrl,
|
|
|
|
|
- lifetimeSec: lifetimeSecForTemporaryUrl,
|
|
|
|
|
- };
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- logger.error('Failed to generate SAS token:', error);
|
|
|
|
|
- throw new Error('Failed to generate temporary URL');
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 5. メモリ使用量監視の追加
|
|
|
|
|
-```typescript
|
|
|
|
|
-class AzureMemoryMonitor {
|
|
|
|
|
- static logMemoryUsage(operation: string): void {
|
|
|
|
|
- const mem = process.memoryUsage();
|
|
|
|
|
- logger.debug(`Azure ${operation} memory usage:`, {
|
|
|
|
|
- heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + ' MB',
|
|
|
|
|
- heapTotal: Math.round(mem.heapTotal / 1024 / 1024) + ' MB',
|
|
|
|
|
- external: Math.round(mem.external / 1024 / 1024) + ' MB',
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- static async monitorAsyncOperation<T>(operation: string, fn: () => Promise<T>): Promise<T> {
|
|
|
|
|
- this.logMemoryUsage(`${operation}_start`);
|
|
|
|
|
- try {
|
|
|
|
|
- const result = await fn();
|
|
|
|
|
- this.logMemoryUsage(`${operation}_success`);
|
|
|
|
|
- return result;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- this.logMemoryUsage(`${operation}_error`);
|
|
|
|
|
- throw error;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-## 🎯 優先順位
|
|
|
|
|
-
|
|
|
|
|
-1. **即座に対応すべき**: 高リスク項目 1-3(クライアント管理、重複作成、ストリーム管理)
|
|
|
|
|
-2. **短期間で対応**: 中リスク項目 4-6(アップロード処理、認証管理、オブジェクト作成)
|
|
|
|
|
-3. **中長期で検討**: 低リスク項目 7-8(最適化事項)
|
|
|
|
|
-
|
|
|
|
|
-## 📊 影響予測
|
|
|
|
|
-
|
|
|
|
|
-- **修正前**: 長時間稼働時に数百MB~GB単位のメモリリーク可能性
|
|
|
|
|
-- **修正後**: メモリ使用量の安定化、リーク率 90% 以上削減予想
|
|
|
|
|
-
|
|
|
|
|
-## 🔍 継続監視項目
|
|
|
|
|
|
|
+**影響度**: 中 - 認証処理の頻度に依存
|
|
|
|
|
|
|
|
-- ヒープメモリ使用量の推移
|
|
|
|
|
-- Azure接続プールの状態
|
|
|
|
|
-- ストリーム処理での例外発生率
|
|
|
|
|
-- SASトークン生成の成功率
|
|
|
|
|
-- 認証トークンのキャッシュ効率
|
|
|
|
|
|
|
|
|
|
---
|
|
---
|
|
|
**作成日**: 2025年9月12日
|
|
**作成日**: 2025年9月12日
|