iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 18 篇

Day 18|伺服器崩潰危機:FileInterceptor() 如何在尖峰時刻榨乾你的記憶體

  • 分享至 

  • xImage
  •  

順利闖過前幾天對 DTO 驗證、查詢參數陷阱的重重關卡,今天我們來到了「請求解析」階段的終站——「檔案上傳」,這背後藏著一個容易被忽略的記憶體危機。

NestJS 提供了便利的 FileInterceptor(),讓檔案上傳幾乎能開箱即用。平常測試幾十 KiB 的小檔案時沒什麼感覺,但若直接套用預設設定而沒補上資源限制,一旦碰上幾百 MiB 的大檔案,或是遇到尖峰時刻多人同時上傳,Node.js process 的記憶體就會瞬間被撐爆,直接釀成 OOM(Out of Memory)崩潰。

最棘手的是,這種記憶體問題很難一眼從 Controller 內的處理邏輯看出來——因為等到 Controller 拿到檔案時,災難其實早就發生了。

本文以 NestJS 搭配 Express adapter 為例,來拆解這個預設行為帶來的災難。

問題怎麼發生?

在 Express 環境下,NestJS 的 FileInterceptor() 底層其實是交給 Multer 來解析 multipart/form-data。如果你在攔截器中沒有特別設定 storage 或 dest,Multer 就會預設選用 MemoryStorage(記憶體儲存),且它對檔案大小的預設值是無上限。

這兩個預設值組合在一起,就成了我們今天要處理的未爆彈:檔案整包進入記憶體,而且沒有限制最多能收多大。

假設我們有一支上傳封面 API:

@Post(':postId/cover')
@UseInterceptors(FileInterceptor('file'))
uploadCover(
  @Param('postId') postId: string,
  @UploadedFile(
    new ParseFilePipe({ fileIsRequired: true })
  )
  file: Express.Multer.File,
) {
  return {
    postId,
    originalName: file.originalname,
    size: file.size,
    storage: 'memory',
    hasBuffer: Buffer.isBuffer(file.buffer),
  };
}

當我們建立一個小檔案並上傳測試:

# 建立一個 17 bytes 的測試檔(內容是 "small cover image")
printf 'small cover image' > /tmp/day18-small.bin

curl -i -X POST http://localhost:3000/posts/42/cover \
  -F 'file=@/tmp/day18-small.bin'

會得到這樣的結果:

{
  "postId": "42",
  "originalName": "day18-small.bin",
  "size": 17,
  "storage": "memory",
  "hasBuffer": true
}

留意範例回應裡的 "hasBuffer": true。在 MemoryStorage 模式下,當 Controller 拿到 file 時,傳進來的早已不是能在網路上分段處理的 Stream,而是整包完整塞進記憶體裡的 file.buffer 了。

單次上傳或許無感,但把檔案大小乘上併發量(Concurrency),風險就會瞬間放大。假設同時有 20 個請求各自上傳 50 MiB 的檔案:

20 × 50 MiB ≈ 1000 MiB(近 1 GiB)

光是存放這些檔案內容,瞬間就會吃掉將近 1 GiB 的 RAM,這還不包含 NestJS 本身、multipart parser 解析消耗,以及應用程式其他邏輯所需要的記憶體。

根因:Controller 執行前,檔案就已經處理完了

為什麼不能在 Controller 裡面擋下大檔案就好?

這跟 NestJS 處理請求的生命週期有關。整個上傳流程大致如下:

https://ithelp.ithome.com.tw/upload/images/20261002/20184306EpIYqKDbCG.png

發現了嗎?一般的 Interceptor 會先把檔案存進 Memory 或 Disk,處理完畢後才把控制權交給 Controller。

所以,當你的 Controller 準備要執行時,那 100 MiB 早就已經躺在記憶體裡了。這時你再說「這個檔案太大我不要收」,早就為時已晚。

陷阱:加上 MaxFileSizeValidator 就安全了嗎?

有些人可能會想到用 NestJS 內建 Pipe 驗證:

@Post(':postId/cover')
@UseInterceptors(FileInterceptor('file'))
uploadCover(
  @Param('postId') postId: string,
  @UploadedFile(
    new ParseFilePipe({
      fileIsRequired: true,
      validators: [
        new MaxFileSizeValidator({ maxSize: 1024 * 1024 }), // 限制大小
      ],
    }),
  )
  file: Express.Multer.File,
) {
  // ...
}

API 確實擋下了大於 1 MiB 的檔案並回傳 400 Bad Request,但這並沒有解決記憶體被撐爆的問題。

在 NestJS 的請求生命週期中,Interceptor 的執行順序是早於 Pipe 的。

回頭看剛剛的流程,FileInterceptor 會最先攔截請求並觸發 Multer 處理檔案。等到 Multer 把檔案完整塞進 MemoryStorage、建立好 file.buffer 之後,才會把資料往下交給負責資料驗證的 ParseFilePipe 與 MaxFileSizeValidator。

這意味著:就算你的 Pipe 規定只能收 1 MiB,如果使用者傳了一個 500 MiB 的檔案,伺服器依舊會先吃下 500 MiB 的 RAM 建立 Buffer,然後 Validator 才會拿這個 Buffer 去判斷。

檔案上傳的防雷關鍵,在於區分**「資源消耗」與「業務驗證」**——在判斷檔案合不合法之前,必須先搞清楚:資料會先存放在哪裡?傳輸階段的容量上限又是多少?

排雷指南

為了解決這個問題,我們必須從 Multer 的底層設定下手,建立真正的防護網。

核心防禦:限制接收大小

真正能防止檔案無限制灌入記憶體的設定,是 Multer options 裡的 limits:

export const MAX_FILE_SIZE = 1024 * 1024; // 1 MiB

@UseInterceptors(
  FileInterceptor('file', {
    limits: {
      fileSize: MAX_FILE_SIZE,
    },
  }),
)

當設定了 limits.fileSize,Multer 會把這個限制交給底層的 multipart parser。一旦接收中的檔案超過 1 MiB,Multer 會進入錯誤流程,不再讓超出限制的內容繼續累積進 Storage,並回傳 HTTP 413 Payload Too Large。這能在源頭阻斷攻擊,不會讓大檔案繼續塞滿記憶體。

進階設定:用 DiskStorage 替換 MemoryStorage

如果你預期收到的檔案較大(例如 50 MiB、1 GiB 的文件或影片),就不該讓它們整包停留在記憶體。這時我們可以設定 dest 將其轉向硬碟:

export const UPLOAD_DIRECTORY = join(tmpdir(), 'nestjs-day18-file-upload');

@Post(':postId/cover')
@UseInterceptors(
  FileInterceptor('file', {
    dest: UPLOAD_DIRECTORY,
    limits: { fileSize: 50 * 1024 * 1024 }, // 記得還是要限制大小!
  }),
)
async uploadCover(
  @Param('postId') postId: string,
  @UploadedFile(
    new ParseFilePipe({ fileIsRequired: true })
  )
  file: Express.Multer.File,
) {
  return {
    postId,
    storage: 'disk',
    hasBuffer: Buffer.isBuffer(file.buffer), // 這裡會變成 false
  };
}

改為 DiskStorage(磁碟儲存)後,檔案會以串流的方式寫入暫存資料夾。此時 file.buffer 會是 undefined,取而代之的是 file.path 等資訊。雖然網路傳輸和寫入過程依然會消耗一小部分 Buffer,但你可以不用讓整份檔案卡在 Process Memory 中了。

善後處理:暫存檔誰來清?

改用 DiskStorage 雖然解決了記憶體危機,但如果缺乏配套的善後清理機制,也只是把問題從「塞爆記憶體」變成「塞爆硬碟」罷了。

如果後續處理都在 Controller 或 Service 中進行,可以先用 try...finally,確保進入 Handler 後,不論業務邏輯成功或拋出 Exception,都會嘗試清除暫存檔:

@Post(':postId/cover')
@UseInterceptors(
  FileInterceptor('file', {
    dest: UPLOAD_DIRECTORY,
    limits: { fileSize: 50 * 1024 * 1024 },
  }),
)
async uploadCover(
  @Param('postId') postId: string,
  @UploadedFile(
    new ParseFilePipe({ fileIsRequired: true })
  )
  file: Express.Multer.File,
) {
  try {
    // 商業邏輯:壓縮、上傳 S3、寫入 DB 等
    return {
      postId,
      success: true,
    };
  } finally {
    if (file?.path) {
      await unlink(file.path).catch(() => {});
    }
  }
}

💡 補充說明:遇到超大檔案怎麼辦?
只要不需要後端即時處理或轉換內容,就不建議讓資料經過 Node.js Server。
可參考 NestJS 官方 File Storage:由前端拿 Signed Upload URL(Presigned URL)直傳 Cloudflare R2 / S3,NestJS 只處理權限驗證與 metadata 紀錄,從根本避免資源消耗。

總結

  1. 釐清執行順序與防線:FileInterceptor(Multer)會先處理並儲存檔案,才進入 Pipe 與 Controller。因此 MaxFileSizeValidator 僅負責商業規則驗證;唯有設定 limits.fileSize 才能在傳輸階段阻斷資源過度消耗。
  2. 依情境搭配 Storage 與清理:小檔案在有 limits 防護下可安心使用 MemoryStorage;若想避免完整檔案佔用記憶體則改用 DiskStorage,並務必搭配 finally 做好暫存檔清理。
  3. 巨型檔案避開全量載入:若無需後端即時加工,應優先採用 Presigned URL 直傳 Object Storage;若仍需後端處理,則應改用 Streaming 串流解析,而非將完整檔案塞進記憶體。

參考資料


上一篇
Day 17|消失的陣列:當查詢參數只有一個值時,為何變成字串?
下一篇
Day 19|遺失的實體:為什麼 TypeORM 找不到 Entity?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言