三種假的安全感:管線、標籤與唯讀掛載

log 管線兩天沒有任何內容而沒人發現、staging 送出的每一個事件都標成 production、 docker.sock 掛唯讀對 Docker API 零保護。三個看起來已經處理好、實際上什麼都沒有保護到的 設定,以及它們共同的成本。

有一類設定問題不會造成故障。它造成的是你以為自己有某個能力,而那個能力其實不存在。

這比故障貴,因為故障會逼你去看,而假的安全感讓你不去看。更糟的是:你會拿那個不存在的能力去做取捨——「這個不能拿掉,它是我們唯一的偵測手段」——而被保護的是一條空管線。

這篇是三個這樣的設定。

一、log_min_messages=fatal 讓 log 管線結構性地送不出東西

症狀

替資料庫加上 DDL 稽核之後做驗證:建一張測試表,去 log 裡找對應紀錄——找不到。

順著查下去才發現整條 log 管線已經空了兩天,而沒有人發現:

觀察值
容器 log 的最後一筆一筆 FATAL:the database system is starting up
log 聚合端最新一筆同一秒
近一小時筆數0

管線本身沒有壞。容器 healthy、收集器在跑、查詢端查得動、schema 正常。它只是結構上送不出任何東西。

根因

部署設定裡帶了 -c log_min_messages=fatal。

而這是上游官方 compose 檔的原始設定——註解說明是為了不讓輪詢查詢把 log 洗版。

PostgreSQL 的 log_min_messages 嚴重度序是:

… < WARNING < ERROR < LOG < FATAL < PANIC

注意 LOG 排在 ERROR 之後、FATAL 之前。這個排序違反直覺——「LOG」聽起來像是最低的層級,實際上它比 ERROR 還高。

設成 fatal 會壓掉所有 LOG 級訊息,而幾乎所有稽核性訊息都是 LOG 級:

  • DDL 稽核外掛的輸出(LOG)
  • log_connections / log_disconnections(LOG)——連線來源 IP 就在這裡
  • log_checkpoints(LOG,而且這個參數本身預設是 on)

於是這條管線只送得出 FATAL 與 PANIC,平時幾乎恆空。

為什麼這不只是「少幾行 log」

因為它會讓你做出錯誤的取捨。

當你相信「這條管線是我們的稽核能力來源」時,任何要動它的決策都會被否決——不能拿掉這個元件、不能改這個設定、不能簡化這個拓樸,因為那會關掉我們唯一的偵測手段。

而被保護的是一條當時幾乎沒有內容的管線。

建立在虛的偵測能力上的取捨,會做出錯的取捨。

這一句是我們從這件事拿到最貴的收穫。它適用的範圍遠比 log 設定廣:任何「因為 X 提供了某個能力所以不能動 X」的論證,都應該先驗證 X 真的提供了那個能力。

還有一個相關的陷阱:繞過過濾器進來的訊息,不構成過濾器設定正確的證據。子程序直接噴到 stderr 的東西會繞過 log_min_messages 進到容器 log。如果你剛好是靠那種訊息發現問題的,那是運氣,不是偵測設計。

判定

不要看管線的健康狀態,看它有沒有內容:

SHOW log_min_messages;

回 fatal 或 panic → 稽核訊息全部被壓掉。

以及一條更泛用的檢查:對每一條 log 管線,問「最近一小時有幾筆」。答案是 0 而系統一直在跑,管線就是空的,不管它的健康檢查多綠。

修法

改成 warning(PostgreSQL 預設值)或依實際需求調整。上游設 fatal 的原因是輪詢查詢洗版——正確的解法是針對那些查詢降噪,不是把整個 LOG 級別關掉。

用一個粗粒度的開關解決一個細粒度的問題,代價通常是關掉一整類你還需要的東西。

二、Build mode 區分不了 staging 與 production

症狀

production 的告警被一個明顯來自 staging 的事件觸發。往回查才發現:錯誤追蹤系統上線以來,staging 送出的每一個事件都標成 environment=production。

在被觸發之前沒有任何訊號。應用程式行為正常、typecheck 與 lint 與全套測試綠、錯誤追蹤系統本身也沒有報錯。唯一的外顯是「production 的 error rate 比預期高一點」,而那看起來只是 production 真的比較多錯。

根因

// client 端
environment: import.meta.env.MODE || 'production',
// server 端
environment: import.meta.env.NODE_ENV || 'production',

staging 與 production 部署的是同一份 image、同一次 build 的產物。因此 NODE_ENV 與 import.meta.env.MODE 對兩個環境恆等於 'production'。

這兩個值描述的是「用什麼模式 build 的」,不是「build 出來的東西被放到哪裡」。

只要部署拓樸是「一次 build、多處部署」,build 期常數就在物理上不可能攜帶部署目標的資訊。這不是寫法不夠好,是資訊不存在。

|| 'production' 讓事情更糟

那個 fallback 的用意大概是「萬一沒讀到就當 production,比較保守」。實際效果是:注入若真的斷掉,結果仍然是 'production',於是「壞掉」與「正常」外觀完全相同。

一個 fallback 如果與正常值相同,它就消滅了「異常」這個狀態。這類 fallback 值得逐個檢查——保守的預設值只有在它與正常值可區分的時候才是保守的。

知識已經在同一個檔案裡了

這件事最值得記的部分在這裡。

同一份設定檔的另一段註解,逐字寫著這個洞察——某個破壞性的 admin endpoint 的 guard 刻意用 server-only 的執行期變數而不用 import.meta.env.PROD,理由正是「staging 與 production 共用同一份 build,PROD 區分不了」。

同一個檔案裡的其他消費者沒有繼承這個結論。

知道的人只在自己當下處理的那一個消費點上用了它。這是為什麼「在正確的地方寫了正確的註解」不足以防止同一個坑再犯——註解只約束讀到它的人,而讀它的人通常正在處理別的事。

要讓一個結論真的生效,它得變成機械檢查或唯一的取值管道,不能只是文字。

修法

環境標籤要從執行期取得,不是 build 期:

// 由部署平台在 runtime 注入,staging 與 production 各自不同
environment: useRuntimeConfig().public.appEnv,

而且不要給 fallback。讀不到就讓它是 undefined——一個標成 undefined 的事件會被注意到,一個標成 production 的不會。

講到這裡你可能會想,那各環境各 build 一份 image 不就解決了?可以,但代價是你驗過的那個 artifact 不再是上線的那個——staging 測完的 image 與 production 跑的 image 變成兩個不同的 產物,而「一次 build、多處部署」正是為了消滅這個差異才存在的。要留住那個保證,環境資訊就 只能從執行期進來。這是兩件事的取捨,不是其中一邊比較笨。

三、docker.sock 掛 :ro 對 Docker API 零保護

症狀

盤點某個容器的 docker socket 掛載,compose 寫的是:

volumes:
  - '/var/run/docker.sock:/var/run/docker.sock:ro'

:ro 讓這一行讀起來像是「已經降權了」。實測不是:

$ docker inspect <container> --format 'User=[{{.Config.User}}]'
User=[]                                    # 即 root

$ docker exec <container> id
uid=0(root)

$ docker exec <container> sh -c '[ -w /var/run/docker.sock ] && echo WRITABLE'
WRITABLE

根因

:ro 是 bind mount 的檔案節點屬性,管的是「能不能改寫或刪除 socket 這個 inode」。

而 Docker API 的呼叫是 socket 上的 connect 加上 request/response,跟那個 inode 的寫入權限完全無關。

動作:ro 擋不擋
改寫或刪除 socket 這個 inode 本身擋
POST /containers/create、/exec、任何 Docker API 呼叫完全不擋

能力上限因此是:完整的 Docker API → 起一個 privileged 容器掛載 / → 主機 root。

:ro 在這件事上提供的保護是 0。

為什麼這值得寫下來

因為照公開的加固指南做就會得到這個結果。

現行針對這類自架架構的加固文件,會逐字建議「socket 應該以唯讀方式掛載」。這個建議在字面上不算錯(它確實防止了改寫 inode),但它會被讀成「已經處理了 docker socket 的風險」——而那是完全不同的一件事。

這是我們對「照著最佳實踐做」這件事最具體的一次教訓:一份指南給的是動作,不是保證。動作與保證之間的推導要自己驗,而驗的方法通常很便宜——上面那三行 docker exec 就結束了。

修法

真正能降權的做法只有三類,而且它們互相不能取代:

  1. 不要掛 socket。如果那個容器只需要讀 log,用 log driver 或掛 log 目錄,不要給它 API
  2. 透過 socket proxy,只放行需要的端點(例如只允許 GET /containers/*/logs)
  3. 容器內不要跑 root,並確認 socket 的 group 權限不涵蓋該 user

第一條永遠優先。最強的權限控制是不給權限。

這三個的共同成本

你以為有的能力實際上
log_min_messages=fatal資料庫層的稽核紀錄管線恆空,只有 FATAL 送得出去
build-mode 環境標籤能分辨事件來自哪個環境全部標成 production
docker.sock:rosocket 已經降權完整 Docker API,等同主機 root

三個都不會造成故障,所以三個都可以活很久。而它們的真正成本不是「少了一個能力」,是你根據那個不存在的能力做了決策:不敢動那個元件、相信告警的環境標籤、認為 socket 的風險已經處理。

實務上只有一種辦法:對每一個「我們有 X 保護」的宣稱,寫下它的判定方式,並且真的跑一次。

上面三個的判定分別是一行 SQL、一次跨環境比對、三行 docker exec。成本低到沒有理由不做——只是在踩到之前,沒有理由想到要做。