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 後端,兩個八竿子打不著的服務, 不可能連小數點後一位都同步。
真相很簡單,也很尷尬:它們回報的根本不是自己的數字,是整台機器的。 我以為我在監控服務,其實我裝了好幾支一模一樣的「主機監控」, 然後把同一個數字印在不同的卡片上。
這件事的根源在於:容器不是虛擬機。
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 幾 %」沒有意義,只能問「剛剛那 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
}
這段短短的程式碼裡塞了三個現實世界的坑:
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() 的 times | process.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 的差別。
每一層都合理、每一層都沒說謊,但疊起來之後, 你手上那個看起來很確定的東西,可能跟你以為的完全是兩回事。




