動機
上週 prod 出現一次 incident:使用者上傳 5MB 以上的圖片、後端記憶體飆 1.2GB、撐了 20 秒才回 500。
事後追下來是 image-processor 用 sync API 把整張圖讀進 buffer 再 resize、單 request 就佔住 worker。
這個 PR 把 image-processor 改用 stream API、加 size guard、加 backpressure。
目標:單 request 記憶體上限 80MB、超過直接 413 reject、不再讓單一上傳拖垮 worker。
前後對比
// processor.ts (Before)
export async function processImage(req: Request) {
const buf = await req.arrayBuffer(); // ← 整張讀進記憶體
const img = sharp(Buffer.from(buf)); // ← 又一份 copy
return img.resize(800).toBuffer();
}
// processor.ts (After)
export async function processImage(req: Request) {
if (Number(req.headers['content-length']) > 5_000_000) {
throw new HttpError(413, 'image too large');
}
return pipeline(
req, // ← stream 進
sharp().resize(800), // ← stream 過
res // ← stream 出
);
}
數字
- 單 request 記憶體:
15MB → 0.08MB(local benchmark、Node 20、5MB JPEG) - p95 處理時間:
820ms → 410ms(同硬體、stream 不用等整張讀完) - 10 並發 worker RSS:
180MB → 92MB
一檔一檔看
共改 6 個檔、依重要度排序。每個 file 點開看「為什麼改 + 改了什麼 + 注意什麼」。
src/image/processor.ts
為什麼改:整個 PR 的核心。把 arrayBuffer() 拿掉、改用 pipeline(req, sharp(), res)。
注意:
content-length可被 client 偽造、但作為第一層 guard 還是省 90% 案例。第二層 guard 在middleware/size-limit.ts- error 改用
HttpError不是 throw 普通 Error、是為了讓error-handlermiddleware 接得到
src/middleware/size-limit.ts
為什麼改:第二層 guard、直接從 stream 累計 bytes、超過 5MB 就斷 stream。比 content-length 可靠。
注意:middleware 順序很重要、必須掛在 multer / body parser 之前、不然 stream 已經被消費過。
src/errors/http-error.ts
為什麼改:新增一個帶 statusCode 的 Error class、給 processor 跟未來其他 handler 共用。
注意:已有 AppError、但它沒帶 statusCode、不想破壞既有 contract、所以新建。後續可以考慮統一。
src/middleware/error-handler.ts
為什麼改:讓 error handler 認得 HttpError、用它的 statusCode 回 client。
tests/image-processor.test.ts
為什麼改:加 4 個 test case:(1) 正常 1MB 圖、(2) 5.1MB 圖預期 413、(3) content-length 偽造、(4) 並發 10 個的 RSS 量測。
注意:(4) 用 process.memoryUsage()、CI 跑可能 flaky、加 retry x3。
package.json
為什麼改:沒新增 dep、只把 sharp 從 ^0.32 鎖到 ^0.33、0.32 的 stream API 有個記憶體洩漏 issue 已修。
請特別 review
三個我自己也不太確定的點
- middleware 順序:
size-limit是不是放對位置?我目前掛在app.tsL23、在 multer 前。但跟auth比、是 auth 先還是 size 先?我選 size 先(不浪費資源在驗 token)、但有 case 是 unauth 不該知道 413 嗎? - 新建 HttpError class:應該重用 AppError 還是新建?我選新建(不改現有 contract)、但長遠看是不是該統一?
- 記憶體量測 test:用
process.memoryUsage()在 CI 容易 flaky、我加了 retry x3 + 30% tolerance。覺得合理嗎?還是該 mock 掉這個 test 改放壓測 suite?
測試 / risk
跑過的測試
npm test✓(86 pass / 0 fail)npm run test:integration✓- local benchmark 5MB JPEG x 100 次、p95 410ms、無記憶體洩漏
沒測到的
sharp 對不同格式 stream 行為理論上一致、但 GIF 的 multi-frame 可能例外。建議 staging 跑一輪 mixed format upload 再 merge。
Rollback 計畫
單 commit、可乾淨 revert。size-limit.ts 是新增 file、revert 不影響其他 handler。processor.ts 改動有單元測試覆蓋、revert 跑舊 test 應綠燈。