別用 os 模組量容器資源
監控陷阱
Ricky-
08 月 21,2026

一支看起來很正常的 /api/stats

502 修好之後,站終於整個跑起來了。同一台機器上這時候躺著好幾個容器—— blog 前端、blog 後端、部署後端、還有幾個測驗站。很自然會想知道: 這幾個服務各自吃掉多少資源?

所以我加了一支 /api/stats,讓 fleet-console 可以把數字畫出來。 第一版寫起來毫無懸念,Node 內建的 os 模組什麼都有:

import os from 'os'

export async function GET() {
  const total = os.totalmem()
  const free = os.freemem()
  return NextResponse.json({
    cpuCount: os.cpus().length,
    memUsedMb: (total - free) / 1024 / 1024,
    mem: ((total - free) / total) * 100,
  })
}

跑起來一切正常。有數字、會變動、看起來很合理。 我把它接上儀表板,滿意地去做別的事了。

破綻:兩個服務回報一模一樣的數字

幾天後我在 fleet-console 上同時看著兩個服務的卡片, 發現一件事:它們的記憶體使用率一模一樣。

不是接近,是同一個數字,而且一起上上下下。 一個是 Next.js 前端、一個是 Django 後端,兩個八竿子打不著的服務, 不可能連小數點後一位都同步。

真相很簡單,也很尷尬:它們回報的根本不是自己的數字,是整台機器的。 我以為我在監控服務,其實我裝了好幾支一模一樣的「主機監控」, 然後把同一個數字印在不同的卡片上。

os 模組不知道自己在容器裡

這件事的根源在於:容器不是虛擬機。

Docker 用 namespace 把行程、網路、檔案系統隔開,所以你在容器裡 ps 看不到別人的行程、ls / 看到的是自己的檔案系統。 這些隔離很直覺,也很容易讓人以為「容器裡看到的一切都是容器的」。

資源限制不是靠 namespace,是靠 cgroup,而 cgroup 做的是「限制你用多少」, 不是「改變你看到多少」。所以容器裡的 /proc/meminfo/proc/cpuinfo 照樣是宿主機那一份:

  • os.totalmem() → 讀 /proc/meminfo宿主機的總記憶體
  • os.freemem() → 同上 → 整台機器還剩多少,包含別人用掉的
  • os.cpus() → 讀 /proc/cpuinfo宿主機有幾顆核心, 不管你的容器被限制成 0.5 顆

換句話說,os 模組活在宿主機的世界裡。 你把它放進容器,它不會因此變聰明,它只是不知道有牆這回事。

限制你能用多少,不代表改變你看得到多少

CPU:要量差值,不是量當下

先處理 CPU。這裡除了「量錯對象」還有第二個坑: CPU 使用率本質上不是一個瞬間值,是一段時間的比例。 問「現在 CPU 幾 %」沒有意義,只能問「剛剛那 100 毫秒裡,燒掉了多少 CPU 時間」。

Node 剛好給了對的工具:process.cpuUsage() 回傳這個行程 累計用掉的 CPU 微秒數。把它傳回去當基準,就會得到差值:

function procCpuPct(sampleMs = 100): Promise<number> {
  const startUsage = process.cpuUsage()          // 微秒,累計值
  const startTime = process.hrtime.bigint()
  return new Promise((resolve) => {
    setTimeout(() => {
      const u = process.cpuUsage(startUsage)     // 相對 start 的差值
      const elapsedUs = Number(process.hrtime.bigint() - startTime) / 1000
      const usedUs = u.user + u.system
      const cores = os.cpus().length || 1
      const pct = elapsedUs > 0 ? (usedUs / (elapsedUs * cores)) * 100 : 0
      resolve(Math.round(pct * 10) / 10)
    }, sampleMs)
  })
}

兩個重點。第一,process.cpuUsage() 只算本行程, 同機上別的容器再怎麼燒都不會算進來——這就是原本用 os.cpus() 拿不到的東西。

第二,牆鐘時間要用 process.hrtime.bigint() 而不是 Date.now()hrtime 是單調時鐘,不會因為系統校時而倒退; 用 Date.now() 的話,NTP 一調整你就可能算出負的 CPU 使用率。

記憶體:分子好辦,分母才是問題

記憶體的分子很單純,process.memoryUsage().rss 就是這個行程實際佔用的實體記憶體。

麻煩的是分母。「用了 300MB」這句話本身沒有意義—— 上限是 512MB 還是 32GB,是兩個完全不同的故事。 而容器的上限不在 os.totalmem(),在 cgroup 的檔案裡:

async function memLimitBytes(): Promise<number> {
  const hostMem = os.totalmem()
  const candidates = [
    '/sys/fs/cgroup/memory.max',                    // cgroup v2
    '/sys/fs/cgroup/memory/memory.limit_in_bytes',  // cgroup v1
  ]
  for (const p of candidates) {
    try {
      const raw = (await readFile(p, 'utf8')).trim()
      if (raw === 'max') return hostMem             // v2:沒設限
      const n = Number(raw)
      // v1 沒設限時會是接近 2^63 的超大值;比宿主機還大就當沒設限
      if (Number.isFinite(n) && n > 0 && n < hostMem * 2) return n
    } catch { /* 檔案不存在(非容器/不同 cgroup 版本)→ 試下一個 */ }
  }
  return hostMem
}

這段短短的程式碼裡塞了三個現實世界的坑:

  • v1 和 v2 路徑不一樣。同一份程式碼要跑在不同機器上, 兩個都試一遍最省事。
  • 「沒有限制」有兩種長相。cgroup v2 會直接寫字串 max;v1 則是給你一個接近 2^63 的天文數字。 前者 Number() 會變 NaN,後者會安靜地變成 「上限 8 EB」,然後你的記憶體使用率永遠顯示 0.0%。
  • 沒跑在容器裡也要能動。本機 npm run dev 時這些檔案根本不存在, catch 掉、退回整機記憶體,至少數字還有意義。

最後一點值得多說一句:n < hostMem * 2 這個判斷看起來很土, 但它擋掉的正是「天文數字」那種情況。與其去記 v1 的 magic number 到底是 9223372036854771712 還是別的,不如用一個誰都看得懂的常識判斷: 容器的記憶體上限不可能是宿主機的兩倍。

改對之後的對照表

要量的東西錯(os,看到的是整台機)對(本服務)
CPU 使用率os.cpus() 的 timesprocess.cpuUsage() 取差值
記憶體用量os.totalmem() - os.freemem()process.memoryUsage().rss
記憶體分母os.totalmem()cgroup memory.max(讀不到才退回整機)
牆鐘時間Date.now()(會被校時影響)process.hrtime.bigint()

沒有錯誤訊息的錯,最危險

上一篇的主角是一則會騙人的錯誤訊息:瀏覽器指著 CORS 大喊, 真正倒下的是上游。那次除錯花了一個多小時,但至少它一直在對我尖叫, 我知道有事情壞了。

這一次完全相反。沒有任何錯誤訊息。 沒有 exception、沒有 500、log 乾乾淨淨,API 回的 JSON 格式完全正確, 數字會隨時間變動、看起來就是一份健康的監控資料。

如果不是我剛好把兩張卡片並排看到,這個錯可以在生產環境活好幾個月。 而且它壞得很惡毒——監控系統的用途就是「出事時告訴你」, 一份安靜地量錯對象的監控,會讓你在真正該擔心的時候更有信心

所以真正的教訓不是「記得用 process 不要用 os」,而是: 會報錯的東西,錯誤訊息至少會逼你去看它;不會報錯的東西, 只能靠你主動去懷疑它。而人不會去懷疑一個看起來很正常的數字。

這種東西唯一的抓法是找一個「應該不成立的等式」去驗。像這次的: 兩個完全不同的服務,數字不該一模一樣。 你不需要知道正確答案是多少,只需要知道什麼樣的答案一定是錯的

不需要知道正確答案,只需要知道什麼樣的答案一定是錯的

而改好的版本裡,還藏著同一個錯

寫這篇的時候,我回頭再讀一次自己改好的 procCpuPct(), 然後在裡面看到這一行:

const cores = os.cpus().length || 1

os.cpus()。整篇文章都在講不要用它,結果它就活在我的修正版裡, 當著 CPU 使用率的分母。

後果是這樣:假設宿主機有 8 顆核心,而我的容器被限制成 1 顆。 當這個行程把它分到的那顆核心吃滿時, usedUs 會等於 elapsedUs,於是

pct = 100000 / (100000 * 8) * 100 = 12.5%

容器已經滿載了,儀表板上顯示 12.5%。 而且我當初還在那行上面寫了註解說「100% 代表吃滿一顆核心」—— 照這個算式,100% 其實代表吃滿全部八顆, 一個被限制成 1 顆核心的容器永遠碰不到 12.5% 以上。 註解和程式碼各說各話,兩邊都沒人發現。

正解跟記憶體是同一套:分母也要去 cgroup 拿。 v2 讀 /sys/fs/cgroup/cpu.max,內容是 quota period 兩個數字(例如 200000 100000 = 2 顆核心, max 100000 = 沒設限);v1 則是 cpu.cfs_quota_us 除以 cpu.cfs_period_us

我把這個留在這裡,沒有偷偷改掉再假裝文章一路都是對的。 因為它剛好證明了這篇的主題:我知道這個陷阱、我正在寫一篇關於這個陷阱的文章, 然後我還是踩了。

「量錯對象」不是知識問題,是注意力問題。你可以完全理解 cgroup 和 namespace 的差別, 然後在寫分母的時候順手打了 os.cpus().length,因為那是最順的寫法。

系列到這裡

這個系列從一個很小的痛點開始:版本號寫死在程式碼裡,每次發版都要手改、常常忘記。 一路走到自建發版系統、反向代理上線、線上 502 救火,最後是監控。

回頭看,這幾篇其實都在講同一件事的不同面向—— 你以為你在看的東西,和你實際在看的東西,中間隔著好幾層轉譯。 版本號隔著 build-time 和 runtime; CORS 錯誤隔著 nginx 的錯誤頁; 資源數字隔著 namespace 和 cgroup 的差別。

每一層都合理、每一層都沒說謊,但疊起來之後, 你手上那個看起來很確定的東西,可能跟你以為的完全是兩回事。

Tags:
監控DockercgroupNode.js


相關文章
一鍵發版背後:三個 repo 的分工
一鍵發版背後:三個 repo 的分工
ricky - 2026-07-09T02:00:00Z
一顆「發版」按鈕,背後牽動 fleet-console、部署後端與目標服務三個 repo。本文拆解版本號為什麼不能由前端計算、docker build --build-arg 如何無條件帶入 APP_...
從零開始製作一個ip查詢網站-第四章 在頁面中加入Google廣告
從零開始製作一個ip查詢網站-第四章 在頁面中加入Google廣告
chris - 2025-12-28T04:48:22Z
本篇教學將示範如何在 React 專案中加入 Google AdSense 廣告,從取得廣告程式碼、封裝成可重用的元件,到在 IP 查詢網站中正確顯示廣告,兼顧網站體驗與變現需求。
用 Gmail 寄信自動化:教你申請應用程式專用密碼
用 Gmail 寄信自動化:教你申請應用程式專用密碼
ricky - 2025-11-28T14:09:23Z
完整教學 Google『應用程式專用密碼』設定流程:說明為何需要 App Password、如何啟用兩步驟驗證、申請 16 位專用密碼,以及使用情境(如系統寄信、Blog 通知信)。圖文步驟詳解,確保...
訂閱電子報
隨時掌握最新的創業故事與設計開發過程
內容包含來自麒航團隊的日常分享、專業知識與工具推薦,助你了解更多創業背後的思維與技術。
麒航私房推薦
Clelereve Blog LogoClelereve Blog LogoClelereve Blog Logo
廣告區
Clelereve Blog Logo
聯絡我們
任何合作或問題請洽:
clelereve@gmail.com
Follow us
Copyright © 2025 blog.clelereve.com - All Rights Reserved
Frontend Version: -- | Backend Version: --
創業實驗筆記 - Clelereve Blog | 別用 os 模組量容器資源 | Clelereve Blog