題幹與適用情境
一個 Linux API 服務在穩定流量下執行數小時後開始拒絕新連線,存取上游、開啟日誌檔案等操作也陸續失敗。已知:
- 服務行程的
RLIMIT_NOFILE軟限制是 8,192,硬限制是 65,536; - 行程啟動時約有 400 個開啟的檔案描述符,之後以約每分鐘 25 個的速度持續增加;
- 故障前
/proc/<pid>/fd中的項目接近 8,192,日誌包含EMFILE: Too many open files; - 重新啟動行程後立即恢復,但增加趨勢再次出現;
- 主機的
/proc/sys/fs/file-nr仍遠低於/proc/sys/fs/file-max。
請解釋檔案描述符、行程檔案描述符表和 open file description 的關係,說明一般檔案、socket、pipe 與 epoll 為什麼都會消耗檔案描述符,並區分 EMFILE 與 ENFILE。接著給出一套生產環境可執行的診斷順序,判斷這是檔案描述符洩漏還是合理併發超過容量,提出暫時止損與長期修復方案,並說明如何證明問題已經解決。
這道題適合後端、SRE、基礎設施、系統軟體和通用軟體工程職位。8,192、65,536、400 和每分鐘 25 個都是虛構練習資料,不代表統一建議值。真實系統還要考慮服務管理器、容器執行環境、行程權限和業務併發模型。
面試官考察重點
第一,候選人是否理解檔案描述符只是行程內的小整數索引,不是檔案路徑、inode 或核心物件本身。強回答會畫出「行程 FD 表 → open file description → 檔案、socket、pipe 等物件」的引用關係,也知道多個 FD 可以透過 dup 或 fork 指向同一個 open file description。
第二,能否準確區分限制層級。EMFILE 表示目前行程已達到 RLIMIT_NOFILE;ENFILE 表示系統級開啟檔案上限耗盡。只看 fs.file-max、只執行目前 shell 的 ulimit -n,都不能證明故障行程的實際限制。
第三,診斷是否同時關注數量、類型和趨勢。單張 lsof 快照只能說明某一瞬間開啟了什麼;要判斷洩漏,還需要在穩定流量下觀察 FD 總數與各類型數量是否持續單調增加,並把它們連結到連線池、請求生命週期、日誌輪替、子行程和例外路徑。
第四,候選人能否區分暫時擴容與根因修復。提高限制可以爭取處置時間,也可能是合理容量規劃的一部分,但它不會自動關閉失去所有權的資源。若 FD 仍以固定斜率增加,更高限制只會延後故障。
第五,是否有驗證閉環。修復後不能只說「錯誤消失了」;還要驗證 FD 使用率、增加斜率、不同物件類型、請求錯誤率、尾端延遲、池內資源和重新啟動週期,並在目標尖峰流量與故障注入下確認資源能回到穩定平台。
回答前需要釐清的問題
- 準確的 errno 是什麼? 應從應用程式或系統呼叫錯誤確認是
EMFILE還是ENFILE,不要只根據人類可讀的 “Too many open files” 猜測層級。 - 哪個 PID 真正失敗? 主行程、worker、sidecar 和短生命週期子行程可能繼承不同限制,也可能分別持有資源。
- 服務由誰啟動? 互動式 shell、systemd、容器執行環境和行程管理器可能設定不同的軟硬限制。目前 shell 的
ulimit不會修改已經執行的服務。 - FD 增加跟什麼指標同步? 需要對齊併發連線、上游請求、佇列深度、檔案輪替、熱更新、子行程數量和例外率。
- 增加的物件類型是什麼? socket、一般檔案、pipe、
anon_inode:[eventpoll]、inotify 和已刪除檔案對應不同的所有權路徑。 - 服務的正常容量模型是什麼? 一個高併發代理本來就可能需要大量 socket;「數量大」不等於洩漏,關鍵是它是否符合併發與池大小,並能在負載下降後回落。
- 能否安全檢查
/proc? 讀取其他使用者行程的 FD 可能受權限和 ptrace 規則限制;生產排查要使用最小必要權限,並避免長時間附加高成本追蹤。
30 秒回答框架
「檔案描述符是行程 FD 表中的非負整數索引。FD 表項引用系統級 open file description,後者保存檔案位移和狀態旗標,再指向一般檔案、socket、pipe 或其他核心 I/O 物件。EMFILE 是這個行程達到 RLIMIT_NOFILE,ENFILE 是整台主機達到系統級開啟檔案上限。
我會先確認失敗 PID 和 errno,再讀取 /proc/<pid>/limits、統計 /proc/<pid>/fd,依 socket、pipe、一般檔案和 anon_inode 分組並持續取樣,同時檢查 /proc/sys/fs/file-nr。若穩定流量下 FD 數仍以固定斜率增加、重新啟動歸零且某類物件不斷累積,這是洩漏;若數量隨併發增加後穩定並能回落,則較像容量不足。暫時可以限流、滾動重啟和在容量驗證後提高真實服務限制,長期要修復資源所有權、例外分支、池和繼承問題。最後用尖峰流量下的 FD 使用率與斜率、錯誤率和資源回收證明修復有效。」
分步深入解答
第一步:建立正確的三層引用模型
open() 成功時會傳回一個非負整數。Linux 通常選擇目前行程尚未使用的最小 FD 編號。慣例上的 0、1、2 分別是標準輸入、標準輸出和標準錯誤,但後續編號只是在目前行程中的索引。
可以把核心關係畫成:
行程 FD 表項 → open file description → 底層物件
三層承擔不同職責:
| 層 | 保存什麼 | 關鍵性質 | |---|---|---| | 行程 FD 表項 | FD 編號、close-on-exec 等描述符旗標 | 編號只在對應行程的 FD 表語境內有效 | | open file description | 目前檔案位移、開啟狀態旗標等 | 位於系統級開啟檔案表,可被多個 FD 共用 | | 底層物件 | inode、socket、pipe、裝置或匿名核心物件 | 決定具體 I/O 行為 |
每次獨立 open() 同一路徑通常會建立新的 open file description。dup() 建立的新 FD 則引用同一個 open file description,所以兩個 FD 共用檔案位移和檔案狀態旗標;fork() 後,父子行程對應 FD 也引用相同的 open file description。描述符自身的 close-on-exec 旗標不屬於這個共用狀態。
這解釋了為什麼「FD 42」沒有跨行程的固定含義,也解釋了為什麼只依路徑去重會漏掉問題:同一路徑可以被獨立開啟很多次,多個不同編號也可能共用同一開啟狀態。
第二步:理解哪些資源會占用 FD
Unix 風格介面把多種 I/O 資源統一為可讀、可寫、可等待的描述符:
- 一般檔案和目錄;
- TCP、UDP 與 Unix domain socket;
- 匿名 pipe 和命名 FIFO;
- 終端、裝置和某些虛擬檔案;
epoll、eventfd、timerfd、signalfd、inotify 等匿名核心物件。
/proc/<pid>/fd 為行程每個開啟的 FD 提供一個符號連結。一般檔案通常顯示路徑;socket 與 pipe 常顯示 socket:[inode]、pipe:[inode];epoll 等物件可能顯示 anon_inode:[eventpoll]。
一個事件迴圈只建立一個 epoll FD,不代表它監控的幾千個連線不占 FD。每個被監控的 socket 仍有自己的描述符。反過來,看到少量 anon_inode:[eventpoll] 也不能直接認定事件迴圈洩漏,應查看哪一類物件的數量真正增加。
第三步:區分 EMFILE、ENFILE 與三個限制值
RLIMIT_NOFILE 有軟限制和硬限制。核心執行軟限制;硬限制是一般行程可把軟限制提高到的上限。Linux 文件把它定義為「可開啟的最大 FD 編號加一」,因此軟限制為 8,192 時,可用編號上界不是 8,192。
常見邊界如下:
| 訊號 | 含義 | 優先檢查 | |---|---|---| | EMFILE | 目前行程的開啟 FD 數達到 RLIMIT_NOFILE | /proc/<pid>/limits 與 /proc/<pid>/fd | | ENFILE | 系統級開啟檔案上限耗盡 | /proc/sys/fs/file-nr、file-max 與核心日誌 | | /proc/sys/fs/nropen | RLIMITNOFILE 可提高到的核心上限 | 提高硬限制失敗時檢查 |
/proc/sys/fs/file-nr 的第一項是已分配的 file handle 數,第三項對應 file-max。它統計的是系統級 open file description,和簡單加總所有行程 FD 的結果不必相等,因為多個 FD 可以共用同一個 open file description。
本題已經給出明確 EMFILE,而且系統級使用量遠低於 file-max,所以主路徑是單一行程限制。修改 fs.file-max 不會解決目前錯誤。
第四步:先蒐集低風險、可比較的證據
在確認 PID 和權限後,可以從 /proc 做一次低成本快照:
pid=12345
grep 'Max open files' /proc/"$pid"/limits
find /proc/"$pid"/fd -maxdepth 1 -type l 2>/dev/null | wc -l
find /proc/"$pid"/fd -maxdepth 1 -type l -exec readlink {} \; 2>/dev/null |
sed -E 's/socket:\[[0-9]+\]/socket:[id]/; s/pipe:\[[0-9]+\]/pipe:[id]/' |
sort | uniq -c | sort -nr | head -20
cat /proc/sys/fs/file-nr
cat /proc/sys/fs/file-max/proc 是即時視圖,行程可能在遍歷期間開啟、關閉 FD,短生命週期項目也可能消失。因此這組命令適合趨勢診斷,不是原子稽核。應每隔固定時間取樣總數與分類,保留時間戳記,再和業務指標放在同一張圖上。
若 socket 占主要增量,再檢查連線方向、目標位址和 TCP 狀態;若一般檔案增加,查看路徑是否集中在日誌、暫存檔或設定熱載入;若 pipe 增加,檢查子行程和 IPC 生命週期;若 anon_inode 增加,定位 event loop、watcher 或 timer 的註冊與銷毀。
必要時可用 lsof -p <pid>、ss -tanp 或受控的系統呼叫追蹤補充證據,但先從 /proc 和應用程式指標縮小範圍。長時間附加追蹤可能增加生產成本,也可能因權限不足看不到完整資訊。
第五步:用「數量、構成、斜率、回落」區分洩漏與容量不足
判斷洩漏不能只比較「目前 FD 數是否很高」。可以使用四個問題:
- 數量是否與預期併發匹配? 正常預算至少包括監聽 socket、已接受連線、上游連線池、開啟檔案、pipe、事件物件和安全餘量。
- 構成是否符合架構? 例如上游池設定最多 500 個連線,但某上游 socket 已累積到 5,000 個,就需要檢查歸還與關閉路徑。
- 穩定負載下斜率是否持續為正? 若請求量和併發不變,FD 仍每分鐘增加 25 個,洩漏證據很強。
- 負載下降後是否回落到平台值? 合理的短連線、請求級檔案和暫時 pipe 應被釋放;長期池連線可能保留,但應穩定在明確上限。
本題中,啟動約 400 個、固定斜率增加、重新啟動歸零後再現,已經明顯偏向洩漏。仍需依類型確認根因,避免把自然成長的連線池誤判成未關閉檔案。
容量不足通常呈現不同形態:FD 數隨併發增加,在池和連線上限附近形成平台;流量下降或逾時到期後回落;物件構成符合設定;故障只在合法尖峰超過原容量預算時發生。此時提高限制、擴大執行個體數或減少單連線消耗可以是長期方案的一部分。
第六步:沿資源所有權檢查常見洩漏路徑
強回答會問「誰建立、誰關閉、例外時由誰接管」,而不是只列命令。常見根因包括:
- HTTP 用戶端沒有關閉回應內容,連線既不能重用也不能及時釋放;
- 資料庫、快取或上游連線取出後,逾時與例外分支沒有歸還池;
- 日誌輪替或設定熱載入反覆開啟新檔案,卻沒有關閉舊句柄;
- 每個請求建立 timer、watcher、pipe 或事件物件,取消請求時缺少清理;
- 子行程標準串流或 IPC pipe 沒有在父子兩側關閉不需要的端點;
- FD 在
exec時意外繼承,導致另一個行程繼續持有本應釋放的資源; - 重試邏輯在失敗後建立新連線,但舊嘗試仍被掛起。
資源生命週期應在程式碼結構中可見:使用 defer、finally、RAII 或框架提供的作用域管理;取得資源後立即建立清理責任;對池、併發、佇列和重試設定有界上限;讓取消、逾時和提前回傳路徑經過同一清理邏輯。
多執行緒程式中,若先 open() 再單獨設定 FDCLOEXEC,其他執行緒可能在兩步之間執行 fork 與 exec。能使用時應在建立階段原子設定 OCLOEXEC,減少繼承競態。這是防止子行程意外持有 FD 的措施,不取代正常的 close()。
第七步:把暫時止損和長期修復分開
故障期間可以依風險採用:
- 限制新流量或降低單一執行個體併發,避免行程完全失去開啟日誌、連線和設定檔的能力;
- 滾動重新啟動洩漏執行個體,保留至少一個診斷樣本,並避免所有執行個體同時重新啟動;
- 在確認記憶體、核心成本和下游容量後,提高實際服務行程的軟硬限制,爭取修復時間;
- 擴充執行個體,把合法併發分散到更多行程。
提高目前終端機的 ulimit -n 對已經執行的服務無效。應修改真正建立該行程的服務管理器、容器或執行環境設定,並在重新啟動後讀取 /proc/<new-pid>/limits 驗證。不能只相信設定檔已經寫入。
長期修復應針對已確認的增加物件:補齊關閉路徑、限制池與併發、修復輪替和 watcher 生命週期、設定合理逾時、避免錯誤繼承,並為每類資源增加可觀測的目前值和建立/釋放計數。
第八步:用容量預算和斜率證明修復
先為單一執行個體建立 FD 預算:
基線 FD + 尖峰入站連線 + 尖峰出站連線 + 池與檔案 + IPC/事件物件 + 安全餘量
預算必須來自真實架構與壓力測試,不存在所有服務通用的固定百分比。限制值還要為診斷和故障恢復保留空間,避免一到尖峰就連日誌、健康檢查和控制連線都無法建立。
修復驗收至少包含:
- 在目標尖峰流量和故障注入下,FD 總數上升後形成穩定平台;
- 流量下降和逾時結束後,短生命週期 FD 回到預期基線;
- 原先增加的物件類型不再保持正斜率;
EMFILE/ENFILE、連線失敗和檔案開啟失敗為零;- 請求 p95/p99、池等待時間和重試量沒有因過度限流而惡化;
- 多次部署、日誌輪替、設定重載和子行程啟動不會留下階梯式增加;
- 監控同時覆蓋
processopenfds、processmaxfds、使用率、增加速率和主要物件池。
「壓力測試 10 分鐘沒報錯」仍可能漏掉每分鐘只洩漏少量 FD 的問題。測試時間應覆蓋原問題出現所需的資源增加量,或透過放大相關路徑驗證建立與釋放計數最終平衡。
高品質示範回答
「我會先確認日誌中的 errno 是 EMFILE,失敗的是 API worker 本身。檔案描述符是行程 FD 表裡的非負整數索引;表項引用系統級 open file description,後者保存檔案位移和狀態旗標,再關聯一般檔案、socket、pipe 或匿名核心物件。dup 和 fork 可以讓多個 FD 共用同一個 open file description,所以 FD 數和系統級 file handle 數不是同一個統計口徑。
EMFILE 表示該行程達到 RLIMIT_NOFILE,ENFILE 才表示整台主機達到 file-max。本題行程軟限制為 8,192,故障前 FD 數也接近它,而 file-nr 遠低於 file-max,因此修改系統級 fs.file-max 沒有針對性。
我會讀取 /proc/<pid>/limits 確認實際軟硬限制,連續取樣 /proc/<pid>/fd 的總數和符號連結目標,依 socket、pipe、一般檔案與 anon_inode 分組。接著把每類 FD 的斜率與入站連線、上游池、檔案輪替、子行程和錯誤率對齊。目前穩定流量下從約 400 個開始每分鐘增加 25 個,重新啟動歸零後重複增加,已經很像洩漏;若發現某上游 socket 持續增加,我會繼續檢查回應內容關閉、逾時取消和連線歸還路徑,而不是直接把所有連線歸因於流量。
處置上先限流並滾動重新啟動,保留一個執行個體做證據蒐集。若容量評估允許,可以在真正的服務管理器或容器設定中暫時提高限制,但重新啟動後必須從 /proc/<new-pid>/limits 驗證。長期修復會把資源所有權寫進結構化清理路徑,為連線池、重試、timer 和 watcher 設定上限,並使用 close-on-exec 防止子行程意外繼承。
驗收時我會在目標尖峰和例外路徑下觀察 FD 總數、類型和斜率。正常結果是隨併發上升到預算平台,在流量下降後回到基線;原增加類型不再累積,EMFILE 為零,尾端延遲和池等待也不惡化。只有這些條件都成立,我才認為問題被修復,而不是被更高限制延後。」
常見錯誤
- 把 FD 當作檔案路徑或 inode → FD 只是行程表中的索引 → 說明 FD 表、open file description 與底層物件三層關係。
- 認為只有磁碟檔案消耗 FD → socket、pipe、
epoll、timer 和 watcher 也使用 FD → 依/proc/<pid>/fd目標分類。 - 看到 Too many open files 就修改
fs.file-max→EMFILE與ENFILE屬於不同層級 → 先確認 errno 和故障 PID。 - 用目前 shell 的
ulimit -n代表服務限制 → 已執行服務可能由其他管理器啟動並繼承不同值 → 讀取/proc/<pid>/limits。 - FD 數很高就斷言洩漏 → 高併發服務可能有合理的高平台 → 比較容量模型、類型、斜率和負載下降後的回落。
- 只把限制從 8,192 提到 65,536 → 固定洩漏斜率仍會耗盡新上限 → 把提高限制當成經容量驗證的止損或規劃,不是根因修復。
- 只檢查正常回傳路徑 → 逾時、取消、重試和提前回傳最容易遺漏清理 → 明確每個資源的建立者、所有者和例外接管者。
- 只看一次
lsof→ 快照無法證明增加過程 → 固定間隔取樣,並和業務併發、池與錯誤指標對齊。 - 只驗證錯誤暫時消失 → 重新啟動或更高限制都可能延後重現 → 驗證物件類型、增加斜率、回落和多次生命週期事件。
追問及應對
追問一:為什麼我執行 ulimit -n 看到 65,536,服務仍在 8,192 報錯?
ulimit 通常顯示目前 shell 及其後代的限制。已經由 systemd、容器執行環境或另一個行程管理器啟動的服務不會被這個 shell 命令回溯修改。也可能是主行程與 worker 使用不同設定。應讀取故障 PID 的 /proc/<pid>/limits,修改真正的啟動邊界,並在新 PID 上複核。
追問二:為什麼兩個 FD 指向同一個檔案,讀取位置會互相影響?
若它們由 dup 產生,或來自 fork 前的同一 FD,它們引用同一個 open file description,因此共用檔案位移和檔案狀態旗標。若程式分別呼叫兩次 open(),即使路徑相同,通常也會得到兩個獨立的 open file description,位移互不影響。路徑相同不足以判斷共用關係。
追問三:檔案已經被刪除,為什麼行程仍能透過 FD 使用它?
FD 引用的是 open file description,不是每次 I/O 都重新依路徑查找。刪除目錄項目不會讓現有引用立即失效;核心物件會在最後一個引用關閉後才最終釋放。日誌輪替若只刪除舊路徑而行程沒有關閉舊 FD,既會繼續占用描述符,也可能繼續占用磁碟空間。
追問四:為什麼把 RLIMIT_NOFILE 提高到很大也可能失敗?
一般行程不能把軟限制提高到硬限制以上,也不能任意提高硬限制;Linux 還用 /proc/sys/fs/nropen 限制 RLIMITNOFILE 可提高到的最大值。服務管理器、容器和權限邊界也可能進一步限制設定。修改後必須檢查啟動日誌和新行程的實際 limits。
追問五:一個 epoll 執行個體能監控很多連線,為什麼仍會耗盡 FD?
epoll 執行個體本身占一個 FD,並以 anon_inode:[eventpoll] 出現在 /proc/<pid>/fd。被監控的每個 socket 仍是獨立 FD。epoll 提高等待大量連線的效率,不會把幾千個 socket 合併成一個描述符,也不會替應用程式關閉連線。
追問六:如何為 FD 使用設定告警?
至少監控目前開啟 FD、行程最大 FD、使用率和增加速率,並依執行個體區分。靜態使用率能發現接近上限,斜率能提前發現慢洩漏;連線池、檔案和 watcher 等業務資源還應有自己的目前值。門檻需要結合尖峰預算、擴充時間和處置窗口制定,不能照搬一個通用比例。