我把自己的正式機從頭稽核了一遍:10 個故障,每一個都回報成功
一次部署印出了 Deployment complete!。
每一項後續檢查都同意這個結論:容器全部健康、/up 回 200、首頁和列表頁都正常渲染、migration 跑完沒有錯誤。整條 pipeline 綠燈。
它部署的是上一個版本的程式碼。
這篇文章是過去兩天對 SideShip 自己的正式機做完整稽核的第一手紀錄。這台機器是一台 GCE e2-medium,9.6G 的根檔案系統上跑著 Postgres、Redis、一個 Rails app、nginx 和 certbot,一個非常普通的 side project 部署。稽核找出 10 個真實的故障,全部都修好了,本文所有數字都可以回溯到 git 歷史、程式碼註解,或是我在這台機器上實際量到的輸出。量不出來的數字我就不寫,文章裡有好幾處會直接標明「這個是推論,不是量測」。
這些故障沒有一個是「服務掛了」。它們的共同點是另一件更難處理的事:
每一個故障都產生了一個成功訊號。
部署印出「完成」。頁面回 200。log 大小上限在五個容器上都確認設定好了。備份的 runbook 寫在 repo 裡,讀起來就像一個已經在跑的系統。CSS build 沒有任何警告。docker logs 很安靜。
問題不是「沒有監控」。問題是監控回答的是一個跟你想問的相鄰、但不同的問題。狀態碼回答的是「它還活著嗎」,不是「它是對的嗎」。systemctl list-timers 回答的是「排程裝了嗎」,不是「昨晚那次跑成功了嗎」。docker inspect 回答的是「上限設定了嗎」,不是「這條串流歸這個上限管嗎」。Exit code 0 回答的是「指令跑完了嗎」,不是「有東西改變了嗎」。
文章分成三段:十個故障(附真實數字、以及為什麼沒有東西發現它)、它們的共同結構、以及一份你今晚可以照著跑的 14 項檢查清單。清單裡的每一項我都標了有沒有實際跑過,以及跑出什麼。
關於這篇文章的邊界:這是一台還在線上的機器。我會描述每一道防線的形狀和背後的推理,但不會寫出參數。速率限制的門檻與時間窗、垃圾內容的判定特徵、蜜罐欄位的名字、憑證檔的路徑、帳號、IP,這些都不在文章裡。你要學的是「該檢查什麼、為什麼」,不是「怎麼繞過我們的」。
一、部署印出「完成」,跑的是上一版的程式碼
先講最上面那個。
make deploy 原本是一串步驟:git pull、build、up、migrate、印出 Deployment complete!。
用 root 跑的時候,git pull 會失敗:root 讀得到那個 checkout,但手上沒有 GitHub 的 deploy key。沒有任何一步檢查這件事。於是後面每一步都對著完全沒有變動的工作目錄開開心心地跑完了:image 從舊程式碼重新 build、容器重建、migration 執行、然後印出成功。
同一天,換了一個門,同樣的形狀又出現一次。把 build 搬到 GitHub Actions 之後,部署改成 pull registry 上的 image。如果你 pull 的是 :latest,結構完全一樣:CI 會把 :latest 和 :<sha> 一起發佈,但那是在 push 之後幾分鐘的事;而且 workflow 的 concurrency group 在兩個 commit 靠太近時會取消掉被取代的那一次 run。在那個時間窗裡,git 已經前進了,docker compose pull 抓到的是前一個 commit 的 image,up 用它重啟,migrate 跑完,然後印出 Deployment complete!。服務的是舊程式碼。
為什麼沒有東西發現
因為部署印出了 Deployment complete!。這句話本身就是整個發現。
所有下游檢查都同意它:容器健康、/up 回 200、網站在服務頁面、migration 已套用。健康檢查回答的是「有東西在跑嗎」,永遠不是「跑的是對的東西嗎」。 實際被服務的版本,從來沒有跟你要求的版本比對過。
修法
把部署的 image 釘死在「當下 checkout 的那個 commit」上:sha=$(git rev-parse HEAD),然後 pull :<sha>,永遠不用 :latest。如果 CI 還沒發佈那個 SHA,pull 會非零退出,make 在動到任何東西之前就中止。
commit 訊息裡那句話值得單獨抄出來:
「還沒部署」是一個可以接受的結果;「部署了跟你要求的不一樣的東西」不是。
配套的幾個改動,每一個都對應一個具體的坑:
docker compose pull app拆成獨立的一步,pull 失敗就停在那裡,不會往下走。up加上--no-build。因為 app service 同時宣告了image:和build:,而docker compose up在 image 不存在時會安靜地去 build。這件事我實際對 daemon 驗證過,不是假設。一次例行重啟會因此變成一次沒有防護的大型 build。- 原本「在 .env 釘住 image tag」的建議被刪掉了。因為 compose 會自動載入
.env,一個釘死的 tag 會讓之後每一次部署都解析到同一個舊 image、成功、並回報成功。這是同一個故障換了個更陰險的形狀。 - pull 失敗的訊息現在會依 Docker 自己吐出的錯誤字串分流:
manifest unknown是「CI 還沒發佈」,unauthorized是「憑證問題」,不會再印一句籠統的猜測。
那個 unauthorized 不是假想的。第一次真的用 registry 部署時就踩到了:Docker 的 registry 憑證是綁 UNIX 使用者的,存在該使用者自己的 home 底下,不會透過 docker group 共用,所以 sudo docker login 對「實際跑 make 的那個帳號」一點幫助都沒有。
這件事的一般化很值得記:一條由獨立步驟串成的部署流程,如果只有第一步可能有意義地失敗、其餘每一步不管前面發生什麼都會成功,那這條流程最後印出的成功訊息,什麼都不代表。每一步的成功條件是「這個指令 exit 0」,而不是「系統現在跑的是我要的那個 artefact」。
這一則是質性的。repo 記錄了它確實發生過,但沒有記錄次數,也沒有記錄持續多久。所以我不會給它一個數字。
二、每一頁都回 200,每一張使用者上傳的圖都是壞的
把 build 搬到 CI 的那個 commit,順手做掉了另一件事:它弄丟了一個 gitignore 掉的憑證檔。
這個站的圖片存在雲端物件儲存,Active Storage 認證用的是一份 service account 金鑰。那份金鑰從來沒有進過版控(它就是被 gitignore 的),但在伺服器的 checkout 目錄裡有一份,八個月前有人手動放進去的。
而 Dockerfile 裡有一行 COPY . .。
所以當 build 是在伺服器上跑的時候,那份金鑰就被 COPY . . 從 checkout 撈進 image 裡了。沒有人宣告過這件事,它是一個副作用。 build 換到 GitHub Actions 之後,CI 是從 GitHub checkout 的,而那個檔案在 GitHub 上從來不存在,於是發佈出來的 image 裡就是沒有它。
環境變數還在,所以 production 還是選擇了雲端儲存這條路,然後在認證那一步炸掉。
每一張使用者上傳的圖片都回 500。每一個包含這些圖片的頁面都回 200。
曝險時間
10 小時 15 分:從 build 搬到 CI 的那個 commit(2026-08-04 13:29:36 +0800)到修好的那個 commit(同日 23:45:05 +0800)。
這個時間是從 commit timestamp 推算的,不是量測到的。git 裡沒有記錄「人是什麼時候發現的」。commit 訊息裡只留下一句「今天圖片全掛而我沒發現」。我不會編一個發現的故事出來。紀錄裡確定的是:沒有任何東西偵測到它。
為什麼沒有東西發現
這一則是整份清單裡最好的教材,因為每一層驗證都對它結構性失明,而且失明的原因各不相同:
- 部署後驗證檢查了
/、/projects、/blogs的 HTTP 狀態碼。三個都回 200,因為壞掉的是頁面裡面的<img>,不是頁面本身。 - RSpec 抓不到:測試套件從來不會真的去連雲端物件儲存。
- CI 的 image 斷言抓不到:那些斷言驗的是 runtime user、檔案權限、目錄可寫,不是「一個 CI 從來沒見過的未追蹤檔案在不在」。
- uptime 端點全程回 200。
- 圖片網址是 Active Storage 的 redirect,所以對頁面 HTML 做個天真的 HEAD 根本走不到那個物件。
故障就活在「頁面載入得了」和「頁面是對的」中間那道縫裡,而任何只讀狀態碼的檢查,永遠看不進那道縫。
修法
修法本身就是這則的第二個教訓。金鑰沒有被放回 image 裡,而是從伺服器的 checkout 以唯讀方式 bind-mount 進容器。理由 commit 訊息裡寫得很明白:image 現在會被推到 registry,任何能 pull 的人都能把裡面的東西挖出來,而且就算之後某個 commit 把它移除,它還是永遠留在 layer 歷史裡。憑證屬於主機,不屬於 artefact。
然後補上真正缺的那個東西:一個叫 make smoke 的檢查,接在 make deploy 最後。它會用公開網址抓一個真實的列表頁、從 HTML 裡抽出真正的 Active Storage 網址、跟著 redirect 一路追到物件本身,檢查前 5 個。
而且它是被證明會失敗之後才被信任的:把伺服器上那份憑證換成 {}、重啟 app,smoke 回報 5/5 失敗、非零退出、並直接指出該檢查哪個檔案;把檔案換回來,smoke 回報 5/5 通過。
commit 訊息裡那句話是這整份稽核裡最好的一句:
一個沒被證明會失敗的檢查等於沒有檢查。
一般化的版本:COPY . . 讓你的 image 變成某一個特定檔案系統的函數,而不是你的 repository 的函數。而你只會在換 build 機器的那一天,才知道當初有哪些檔案是承重的。
三、make clean 說它會刪 image;它同時刪掉了正式資料庫
這一則是讀程式碼讀出來的,不是被觸發出來的。資料庫沒有真的掉過。
那個 target 長這樣:
clean:
docker compose down --rmi local --volumes --remove-orphans
--volumes 會刪掉 docker-compose.yml 裡宣告的 named volume。這台機器上那是三個:正式資料庫、使用者上傳的檔案、TLS 憑證。
而 make help 對這個 target 的描述是:「Remove stopped containers and images」。
「volume」這個字在說明文字裡一次都沒有出現。
為什麼監控抓不到
因為監控在定義上就抓不到這一類。它不是一個故障,它是一把上了膛的槍。 在有人執行它之前,什麼事都沒有;執行之後,所有事同時發生,而且不可逆。
它也通過每一項自動檢查:Makefile 語法正確、CI 全綠、target 的行為跟它寫的一模一樣。唯一的偵測器是一個人去讀那個 recipe,然後跟說明文字對照。
Makefile 註解裡把真正的失敗路徑寫了出來,而那正是這則值得公開的原因:
一次部署在 build 到一半的時候磁碟不夠了,操作的人掃過
make help找一個能清出空間的東西,看到clean,然後刪掉正式環境。
人在壓力下會伸手去拿的那個指令,必須是安全的那一個。
修法
clean 改寫成只清停掉的容器、dangling image 和 build cache。它現在碰不到任何 volume。
刻意沒有用 docker image prune -a:-a 會在 stack 剛好停著的時候把當前的 app image 一起刪掉,逼一台本來就撐不起 build 的機器做一次完整重建。
破壞性行為搬到一個新的 destroy-all-data target,要在提示字元打出 DESTROY 才會執行;非互動的呼叫者會讀到空輸入然後中止。make help 多了一個明確的「Danger zone」區塊,而安全那個 target 的說明現在正面陳述它不碰任何 volume、資料庫/上傳檔案/TLS 憑證都是安全的。
同一個家族還有一個相關的設計:pull 的磁碟空間預檢門檻刻意設得比 build 的門檻低。理由是:一個會擋掉安全操作的守衛,會被人繞過;而「一邊被擋一邊到處找磁碟空間」正是 make clean 差點刪掉資料庫的那個情境。
四、log 大小上限在五個容器上都設好了,而它什麼都沒有管到
2026-07-28,磁碟已經很緊了(85%,9.6G 裡剩 1.5G,光 nginx access log 就 109MB),所以我做了一件正確的事:用 YAML anchor 給 docker-compose.yml 裡全部五個服務(db、redis、app、nginx、certbot)都套上 json-file driver 的 max-size 10m / max-file 3,主機的 daemon 設定也加上同一組預設值,讓之後新開的容器都有。
docker inspect 確認過:五個容器都帶著這個上限。驗證通過。
app 容器完全無視它。
因為 config/puma.rb 裡有這一行(Rails 預設 puma.rb 模板留下來的,比容器化還早):
stdout_redirect '/…/puma.stdout.log', '/…/puma.stderr.log', true if rails_env == 'production'
Puma 把自己的 stdout/stderr 重新指向容器內的檔案,而那個路徑掛在一個 named volume 上。
Docker 的 log driver 只管 daemon 從容器 stdout/stderr 上「收到」的東西。 一個在容器裡自己寫檔案的行程,根本走不到 driver 面前。所以那個上限管的是一條空的串流。
檔案無上限地長。而 docker logs app 完全不回任何東西。
數字
- volume 裡累積了 965MB 的 puma log
- 磁碟 87%(根檔案系統總共 9.6G)
docker logs:七天 0 行
為什麼沒有東西發現
這是整份清單裡最鋒利的一個「修復動作親手藏起了自己的失敗」的例子。
加上那個上限「感覺起來」就是把問題解決了。 而且驗證步驟是通過的:docker inspect 確認五個容器都帶著限制。唯一真正重要的那個容器被豁免,原因藏在應用程式的程式碼裡,那個檢查看不到的地方。
更糟的是,這個繞道摧毀了你原本會用來察覺它的那個儀器。docker logs 是空的,於是「看一下 log 有沒有什麼怪東西」這個日常習慣什麼都回不來。而空白讀起來像「很安靜」,不像「壞掉了」。
程式碼註解裡把代價寫得很直接:它「讓 2026-07-31 的 503 outage 完全無法診斷」。
修法
把那一行刪掉,換成一段解釋它為什麼永遠不該回來的註解:production 設定已經把 logger 指到 $stdout;上限在 docker-compose.yml;如果哪天你真的需要 Puma 自己的輸出存成檔案,在主機上做(docker compose logs app > file),不要用 stdout_redirect。
Rails 的輸出回到 stdout,那個 10m x 3 的上限立刻開始生效,可觀測性是順帶救回來的。volume 裡那 965MB 是死重,另外手動清掉一次。app_logs 這個 mount 刻意留著,這樣任何走漏的容器內寫檔至少會落在一個看得到、也清得掉的地方,不會掉進容器的可寫層。
根因講白了就是:兩個彼此獨立的 log 輪替機制被當成同一個。 容器層級的上限是新的、刻意加的控制;stdout_redirect 那一行是繼承來的、比容器化更早的東西。把 stdout 導向檔案對一台裸機上的 Puma 是正確的,在容器裡是恰好相反的錯誤,因為在容器裡 stdout 本身就是 log 的傳輸通道。而且沒有任何東西會警告你:redirect 成功了,上限也套用了,兩邊看起來都設定好了。
五、89,135 個 stack trace,全部來自機器人找 .php 檔
config/routes.rb 的最後是一個 catch-all:
match '*path', to: 'errors#not_found', via: :all,
constraints: ->(req) { !req.path.start_with?('/rails/') }
而 ErrorsController#not_found 裡是一個只有 format.html 和 format.json 的 respond_to 區塊。
機器人在探測 /1.php、/admin.php、/wp-content/plugins/…/wp_filemanager.php 這類路徑時,請求的 format 是 Rails 無法對應到任何已註冊 Mime::Type 的東西,於是 request.formats 回空的、request.format 是 Mime::NullType。
respond_to 在沒有任何一個 clause 命中的時候會丟例外,不會 fallback。 丟出來的是 ActionController::UnknownFormat,在 production 表現成一個 500(不是原本想要的 404),而且 Rails 會為每一次都寫一整份 backtrace 進 log。
同一個 bug 也打到了正常的爬蟲:真正的路由是 get 'sitemap',所以 Bingbot 來要 /sitemap_index.xml 的時候也掉進 catch-all,拿到 500。
數字
線上 log 裡累積了 89,135 次 ActionController::UnknownFormat。
這個數字有一個獨立的旁證。同一天量的一週份 nginx access log:
| 狀態碼 | 一週次數 |
|---|---|
| 301 | 6,496 |
| 404 | 5,330 |
| 500 | 4,232 |
| 400 | 1,236 |
| 200 | 1,347 |
200 是這張表裡第二少的。 一週 4,232 次 500 這個數字,跟 89,135 這個累計值是兩個不同來源,但互相佐證了同一場風暴。背景值:這台機器每天大約承受 1,300 次掃描機器人的請求。
十小時後,同一個形狀從另一扇門回來了
修完的隔天,在檢查「新上的速率限制有沒有誤傷真實使用者」的時候發現:catch-all 也接 POST,而 Rails 預設對每一個 controller 都開啟 forgery protection,所以掃描器對不存在路徑發的 POST(POST /dns-query、POST /vpnsvc/connect.cgi)會丟 ActionController::InvalidAuthenticityToken,也就是一個 422,外加一整份 backtrace。
修完之後的量能看出這個形狀有多常見:6 小時內 6 次,來自 4 個掃描 IP。 量本身很小,但它的規模取決於「今天是誰在掃你」,不取決於你控制得了的任何東西。
修法是給 ErrorsController 加上 skip_forgery_protection。這在這裡是安全的,而且只在這裡安全:這三個 action 只渲染一個靜態錯誤頁,不改變任何狀態,沒有任何狀態可以被偽造。對 POST /dns-query 誠實的回答就是 404。那個路徑不存在,任何 CSRF token 都不會讓它存在。
為什麼沒有東西發現
三個原因疊在一起:
(a) 沒有人會用手去要 /admin.php。 出錯的那條路徑是沒有任何真人會走過的路徑,它只對「你沒在看的那種流量」開放。
(b) 看得到的症狀是「對一個機器人回了 500」,而沒有任何東西被設定成要對 500 告警。
(c) 最關鍵的:代價不是那些錯誤,代價是那些 bytes。 每一次都寫一整份 backtrace,89,135 份全部寫進一個沒有大小上限、而且 docker logs 讀不到的檔案(見上一節)。也就是說,唯一能揭露這件事的那個指標(log 的體積),正好就是被弄成不可讀的那一個。
測試套件也抓不到:RSpec 從來不會送一個帶著未註冊副檔名的請求,而在這次修復之前,errors controller 連一個 request spec 都沒有。
修法
抽出一個共用的 private method,三個 action 共用,關鍵是那個 format.any:
def respond_with_error(status, message)
respond_to do |format|
format.html { render status_page(status), status: status, layout: 'application' }
format.json { render json: { error: message }, status: status }
format.any { head status } # 必須放最後
end
end
format.any 必須放最後,這樣 html 和 json 才會繼續贏得 content negotiation。根因一句話講完:respond_to 是一份白名單,而沒有命中的 format 是一個例外,不是一個 fallback。 一個「整個工作就是回答不存在路徑」的 controller,被寫成好像它只會被要求 html 或 json。
同一個 commit 還修了兩件事。第一件是 :unprocessable_entity 改成 :unprocessable_content(Rack 3.2.4 已經不再對應舊的 symbol,導致 /422 直接丟 ArgumentError)。第二件比較嚴重:
另一個當時正在線上發生的 500:Project.search 去查一個不存在的 description 欄位(description 是 ActionText),所以 /projects 上任何搜尋都回 500。那個 scope 的測試覆蓋率是零,而 commit 訊息把這件事直接寫成它會上線的原因。
這兩個 bug 放在一起很有意思,因為它們是相反的教訓:一個是測試在結構上碰不到(沒有人會寫一個 .php 副檔名的 request spec),一個是單純就沒人測。修復同時補上了 errors controller 的 request spec,29 個 example,後來長到 31 個。
六、Errno::ENOSPC:磁碟是那個緩慢變數
前面兩節餵養的是同一個東西。
2026 年 3 月,這台機器出現了 127 次 Errno::ENOSPC,把 app 弄掛了。
這個數字在 repo 裡出現在三個地方(Makefile、CI workflow、兩則 commit 訊息),但三處都是同一個人事後記下來的,repo 裡沒有留下 log artefact。月份精度,沒有確切日期,我不會給它多餘的精確度。* 它是「當時數的」,不是「現在還能重新數的」。*
2026-07-31 另外有一次 503 outage,完全無法調查,因為 app 的 log 正在被寫進一個 docker logs 讀不到的檔案裡。
這兩件事我要分開講。沒有任何來源說它們是同一次事件,而且 log 上限那次工作(07-28)就卡在兩者中間。另外值得注意的時序:第五節那個 bug 是 2026-08-04 才修好的,所以在整個七月那次 outage 期間,89,135 份 backtrace 還在持續累積。
那些 bytes 是從哪來的
「89,135 份 backtrace → 965MB 的 volume → 磁碟滿」這條線是一個一致的推論,不是單一來源的事實。
commit 訊息說那 89,135 次 UnknownFormat 是「磁碟被撐爆的主因」;另一則 commit 獨立地說 puma 的 log volume 到了 965MB。Rails 的 backtrace 進的是 app log,而 app log 正在被導進那個 volume,所以它們幾乎確定是同一批 bytes。而且那 89,135 這個數字必然是從那個檔案裡數出來的,因為 docker logs 是空的。
但沒有任何一個來源直接這樣寫。所以我把它寫成推理,不是量測。
磁碟的真實形狀(2026-08-04 量的)
| 項目 | 數字 |
|---|---|
| 根檔案系統 | 9.6G |
| 部署後穩定狀態 | 約用掉 6.4-6.5G,剩約 3.2G(67%) |
一次 docker compose build --no-cache 暫時需要 |
約 2.7G |
| 其中 build cache 峰值 | 約 1.7G |
| 其中新 layer | 約 900M |
| 同時間還額外佔著:仍被執行中容器引用的前一個 image | 約 914MB |
那次 build 需要的空間,是當時可用餘裕的大約 87%。 這是 repo 自己記下的數字,而重要的是這個比例,不是把幾項加起來湊得漂不漂亮。而在 2026-08-04 一天的部署過程中,磁碟兩次碰到 93%。
這就是為什麼「磁碟使用率百分比」不是正確的門檻。正確的門檻是「一次部署暫時需要的空間,還留不留得下來」。
為什麼沒有東西發現
完全沒有任何磁碟監控:沒有告警,沒有 dashboard。
磁碟是那個經典的緩慢變數:它會單調地劣化好幾個月、零症狀,然後一次性全部壞掉,而且會把你用來診斷它的機制一起帶走。
還有一個第二序的失敗模式值得知道:磁碟快滿了不會表現成「磁碟滿了」。 在我另外一台機器上,它表現成 Redis 沒辦法寫 RDB、進入 MISCONF 狀態、拒絕所有寫入,於是整站 500。症狀指向的方向跟原因是反的。這一則是回憶,不是這個 repo 記錄下來的東西,所以我不會給它一個持續時間。
而且 ENOSPC 是在寫入的時候發生的,所以第一個壞掉的東西,往往就是那個本來會告訴你出事了的 log。
修法(分層的,跨好幾個 commit)
- json-file driver 上限 10m x 3 套到所有容器,加上主機 daemon 的預設值。
- 移除那個繞過上限的
stdout_redirect(第四節)。 make preflight:可用空間低於一個下限就拒絕開始 build。這裡有一個實作細節值得抄:這個下限要量的是docker info回報的DockerRootDir所在的那個檔案系統,不是/。 在開發者的 macOS 上,/是唯讀的密封系統卷,永遠回報大約 2G,拿它當門檻會毫無理由地擋掉每一次本機 build。make build之後立刻跑docker builder prune -f(實測回收了 841MB);make deploy會 prune 並印出df;另外有一個獨立的、不碰 volume 的make reclaim。- 最後,把 image build 整個搬離主機,改到 GitHub Actions + registry,伺服器只負責 pull。同時加了一個
reclaim-app-images,因為docker image prune只清 dangling image,而每一次 rollback 抓下來的:<sha>都是有 tag 的,等於每 rollback 一次就永久漏掉約 914MB。
七、從來沒有備份過。而且這台機器上根本沒裝 cron
正式資料庫從上線以來從來、完全沒有被備份過。
repo 裡的 BACKUP.md 有一個章節,標題是「Scheduled Backups (Cron)」,教你跑 crontab -e 然後加一行每天凌晨三點的 make backup。
那個程序從來沒有被執行過,而且不可能被執行:這台 Ubuntu 機器上沒有裝 cron。
稽核我是直接上機器看的,沒有靠文件:沒有 crontab、systemctl list-timers 裡沒有任何備份 timer、對整台機器搜尋 .sql / .sql.gz 檔案一個都沒有。
多久
最早的部署文件 commit 是 2025-12-13,而那份雲端儲存的 service account 金鑰也是同一天被手動放到伺服器上的,所以這台機器大約從 2025 年 12 月中開始在服務。備份是 2026-08-04 才做出來的。
大約 7.5 個月、約 234 天,有一個正式資料庫、沒有任何一份可還原的副本。
起始日期請當成「大約 2025 年 12 月中」。它是從 commit 日期推出來的,不是伺服器上的紀錄。
為什麼沒有東西發現
備份是那個標準的零訊號系統:一個在運作的備份和一個根本不存在的備份,每一天的可觀測行為都完全一樣,它們只在一天不一樣:你需要它的那一天。沒有頁面會 500,沒有指標會動。
更糟的是,兩件「感覺起來像驗證」的事,在這裡都會騙你:
- runbook 說備份排程好了。文件被誤認成實作。 一份寫在 repo 裡的程序,讀起來跟一個已經設定好的系統一模一樣,而部署流程裡沒有任何東西會反駁它。而且這份程序在這個 OS image 上根本不可行,所以就算有一個認真的操作者照著做,他也只會拿到一個錯誤然後被迫自己想辦法。
systemctl list-timers(假設真的有 timer)會回報一個健康的下次執行時間,不管前面每一次執行是不是都失敗了。
唯一誠實的檢查是:「最後一次成功的 dump 是什麼時候,以及它還原得回來嗎。」
修法
一個備份腳本,加上 systemd service 與 timer。每一個設計點都對應一個具體的失敗:
- dump 先寫成
.inprogress暫存檔,只有在確認它非空、含有pg_dump的完成標記、而且通過gzip -t之後,才 rename 到正式位置。任何一項失敗就非零退出。 - 備份存在版控 checkout 和 Docker build context 兩者之外的地方。因為 Dockerfile 有
COPY . .,而 Docker 不讀.gitignore,放在 repo 裡的每晚 dump 會被烤進每一個 image layer。(.dockerignore也加了一條,作為縱深防禦。) - 保留策略只量、也只刪這個專案自己那個命名前綴的
*.sql.gz,而且永遠不刪最新的那一份。 早期草稿是用du -sm量整個目錄、但只刪 dump。那表示只要有人在那個目錄裡丟一個不相干的 tarball,這支腳本就會把每一份資料庫備份都刪光,然後回報成功。 - 空間夠的時候先 dump 再清(一次失敗的執行絕不該減少你已經握有的備份);只有空間不夠時才先清再 dump,因為那個時候的選擇已經是「有備份」跟「沒備份」之間了。
- 它會寫一個
last_success狀態檔,讓--status可以在不需要 docker、也不需要資料庫的情況下回答「昨晚那次成功了嗎」。
順帶也換掉了舊的 make backup:它裡面那行 inline pg_dump 完全沒有檢查 exit code,所以在 db 容器停著的時候跑它,會產生一個約 20 bytes、檔名跟真正備份長得一模一樣的檔案。然後它就排進保留佇列,站在真正的 dump 前面。
這個「20 bytes 的假備份」直接催生了清單裡的第 7、8 兩項檢查:檔案存在不等於備份存在,備份存在不等於它裡面有資料。
八、1,778 筆垃圾資料,以及我第一個結論是錯的
POST /feedbacks 這個端點不需要登入、沒有 captcha、沒有速率限制、沒有蜜罐。一個掃描器找到了它,然後把那張表填滿了。
1,778 筆。
當作對照,同一份 2026-08-04 的正式環境快照:7 個使用者、5 個已發佈專案、0 則留言、1 個讚、2 個對話、12 則訊息。
這個站上被使用最多次的寫入端點,是被一個機器人在用的那一個。
全部 1,778 筆都建立於 2026-04-23,其他任何一天都是 0 筆,而且沒有一筆屬於已登入的使用者。從那天到稽核那天是 3 個月又 12 天。
然後是我搞錯的那一半
我把稽核的第一個結論寫進了 commit 訊息:這 1,778 筆全部是同一個 payload 重複,同一組 email、同一段內容。
那個結論,我是看前 5 筆就下的。
隔天,當清理用的 rake task 真的對正式資料庫跑 dry-run 的時候,那個窄的 predicate 只命中 1,778 筆裡的 705 筆。另外 1,073 筆會被一個「回報成功」的清理默默留下來。
那 1,073 筆是 SQL injection 掃描器的 payload,形狀各異,是 sqlmap 那種風格的探測:純數字字串、布林算術恆真式、用 sleep/waitfor 做的延遲探測、還有一個 Oracle DBMS_PIPE.RECEIVE_MESSAGE 探測。它們不是一般意義上的垃圾內容,它們是一個掃描器在這個欄位上四處走動,找一個可以注入的參數。
根因的深層版本是這句:「一次來自單一掃描器的爆量代表只有一個 payload」這個假設是錯的,因為掃描器本來就會刻意變化 payload。那就是「掃描」的意思。
這個錯誤結論至今還留在這個 repo 的四個檔案裡(model、service、initializer、部署文件),因為它們是在被查證之前就寫下來的,而在 dry-run 修正之後沒有被回頭更新。正確的數字在 rake task 的註解和那則更正的 commit 裡。我把這件事留在文章裡而不是藏起來,因為它比一個乾淨的敘事更有用:一個在被檢查之前就被寫下來的發現,會擴散到四個檔案裡去。**
為什麼沒有東西發現
沒有任何東西在數任何東西。
沒有 admin 介面會列出 feedback,沒有指標追蹤資料列成長,沒有 log 記錄一次送出。
一個下午每分鐘四次寫入,在沒有人看的時候,跟零次寫入長得一模一樣。 而在一個 7 個使用者的站上,沒有人在看。
第二序的教訓才是對讀者更有用的那一個:那個「更正」本身也差點是隱形的。 一個只命中子集的清理腳本會 exit 0 並回報成功。如果那個 task 沒有在 dry-run 裡印出「命中數 vs 總數」,1,073 筆會活過一次宣稱成功的清除。
修法
濫用控制的部分我只講形狀,不講參數:
- 對可被濫用的寫入端點加上依 IP 和依身分的限制,再加一個全站的天花板,門檻校準成「一次正常的頁面瀏覽(它散開成的請求數遠比一般人以為的多)永遠不會擋到真實訪客」。那個天花板實際計入哪些請求類別、以及任何一個門檻的數值,都不寫在這篇文章裡。
- 一個隱藏的誘餌表單欄位,加上一個帶簽章、會過期的渲染時間戳。
- 一個環境變數的 kill switch,讓一個誤傷的限制是一次「約 10 秒的
.env修改」,而不是一個「改程式碼 → 過 CI → 重新部署」的循環。 - 每一次 rack-attack 命中都會往 app 用的同一條 stdout 寫一行 log,所以
docker compose logs app | grep rack-attack就能回答「現在有人在打我們嗎」,不需要碰資料庫。這是把「沒有東西在數」直接修掉。
清理 task 的部分則是刻意設了很多閘門:它有一個「整天模式」,但只要那天有任何一筆屬於已登入使用者就中止、日期解析不了就中止、會印出它沒有動到的其他日期有幾筆、而且超過一個最大筆數就拒絕執行。理由是如果 predicate 命中的比那還多,那錯的是 predicate,不是資料。三條中止路徑都對測試資料庫驗過。
九、一個 1.39MB 的 CSS,三分之二是一張「把檔案對應到它自己」的地圖
每一個訪客拿到的編譯後 application.css,是 1,393,531 bytes。
其中 917,917 bytes,也就是 65.9%,不是 CSS。那是 postcss-cli 附加上去的一張 base64 inline sourcemap。
build chain 是這樣的:sass(帶著 --no-source-map,正確)產出 application.css,然後 postcss 讀同一個檔案、就地寫回同一個檔案,跑 autoprefixer。
postcss 沒有被告知任何 map 相關的參數,而它預設會產生 sourcemap。因為它讀寫的是同一個路徑,它產生的那張 map 的 sources 指向 application.css,也就是檔案自己。
一張從檔案指向檔案的地圖,約 918KB,在 render-blocking 的路徑上,每一個訪客在首次繪製之前都得把它下載完。
數字
| 修復前 | 修復後 | 變化 | |
|---|---|---|---|
| 原始大小 | 1,393,531 bytes | 472,411 bytes | -66.1% |
| gzip 後 | 183,803 bytes | 61,844 bytes | -66.4% |
| inline sourcemap | 917,917 bytes(65.9%) | 0 | — |
這兩個修復後的數字我今天在工作目錄裡重新量過一次,跟 commit 訊息記載的完全吻合:wc -c = 472,411、gzip -c | wc -c = 61,844、grep -c sourceMappingURL = 0。
注意這個算術:1,393,531 − 472,411 = 921,120,也就是那張 917,917 bytes 的 map,加上同一個 commit 順手刪掉的約 3.2KB 真正的死 SCSS(幾個被移除功能留下的區塊)。CSS 內容本身一個 byte 都沒有變。
為什麼沒有東西發現
這件事沒有任何一個環節是錯誤。 build 成功、CSS 合法且完整、每一頁都正確渲染、測試通過、沒有任何 console 警告。
而且瀏覽器在 DevTools 沒開的時候根本不會去抓、也不會去解析 inline sourcemap,所以它花掉的是頻寬和首次繪製的延遲,症狀是零。
唯一的偵測器是去看你實際送出去的那個 artefact 有多大。而在一個只有幾個訪客的站上,任何 page-speed 告警都永遠不會響。
修法
在 postcss 那一步加上 --no-map。一個旗標。
刻意沒做的事:Bootstrap tree-shaking。理由寫在 commit 裡:那才是真正有風險的改動,因為壞掉的樣式不會報錯,它只會安靜地變醜,而在一個低流量的站上,那可以好幾個禮拜沒有人發現。
一般化的檢查項目很便宜:在任何 build 工具的改動之後,對你的建置產物跑一次 ls -l,問自己這個數字合不合理。 一個以 MB 計的 stylesheet 是在告訴你某件事,即使什麼都沒壞。
十、社群預覽圖同時以兩種獨立的方式壞掉
兩個各自獨立的故障,任何一個單獨存在都足以讓整站的社群預覽圖全滅。
故障一:專案頁和使用者頁用 url_for(@project.banner.first) 設定 og:image 和 twitter:image。
url_for 對一個 Active Storage attachment 回傳的是 path,不是 URL。 Open Graph 要求絕對網址,所以 Facebook、X、LinkedIn、Slack、Discord 全部渲染不出預覽圖。更糟的是 twitter:card 設的是 summary_large_image。那個版型幾乎整張都是圖,空的時候比不設卡片還難看。
故障二:layout 裡給所有「沒有自己圖片的頁面」用的 fallback 指向 /og-image.png,而那個檔案不存在。在 production 確認過是 404,而且它既不在版控裡,也不在 Dockerfile 裡。
所以首頁、部落格、以及任何沒有 banner 的專案頁,也就是這個站絕大部分可分享的表面,從來就沒有預覽圖。
為什麼沒有東西發現
兩個故障都在「這個網站自己會走過的 request/response 迴圈」之外。
頁面完美渲染、回 200、通過每一個測試。那些 meta tag 確實存在於 HTML 裡,而沒有任何測試斷言過它們的值能解析到任何東西。
損害發生在別人的伺服器上,在 Facebook 或 Slack 去抓那個網址的時候,而且它不會回報給任何人:你這邊沒有錯誤、沒有 log、沒有狀態碼。
對一個成長模式就是「大家互相分享專案連結」的站來說,這是一個正好坐在主要成長路徑上、而且在結構上隱形的故障。
修法
兩頁都改成 rails_blob_url。它會從當前的 request 取得 host 和 scheme,所以就算 app 沒有設 default_url_options 也不可能吐出 localhost。
layout 裡加了一層防禦性的正規化:任何頁面透過 content_for :og_image 傳進來的東西,都會在那裡被強制轉成絕對網址,所以下一個要加預覽圖的人不可能重犯同一個錯。同時加上 og:image:secure_url(當結果是 https 時),以及一個防護,讓對沒有附加頭像的使用者呼叫 rails_blob_url 不會丟例外。
然後生出那張缺席的 fallback 卡片,用設計系統的品牌綠。今天在工作目錄裡驗過:1200 x 630、8-bit RGB、非交錯 PNG、76,823 bytes。
這一則是質性上真實的,但幾乎沒有數字。我不會為它捏造任何互動或點擊數據。
同一個 commit 裡的另外三個同家族故障
- 部落格 sitemap 的
lastmod用的是File.mtime,而在一個 Docker image 裡那是 build 時間,所以每一次部署都在跟爬蟲說「每一篇文章剛剛都更新了」。 /projects沒有自己的 title 和 description,跟首頁 byte-for-byte 完全一樣。- zh-TW 和 en 的文章配對沒有互相的 hreflang,所以搜尋引擎把它們當成重複內容。
以及三個「這次修復自己製造出來、但被測試攔下來」的故障
這一段我覺得比上面那三個更有價值:
- 加上 frontmatter 的
date讓每一篇文章都 500,因為 BlogsController 的YAML.safe_load少了permitted_classes: [Date],而 SitemapsController 一直都有。 - 新加的
hreflang="en"指向一個回傳<html lang="zh-TW">的網址,因為 BlogsController 從來沒有依文章語言設定I18n.locale。一個自相矛盾的 hreflang 比沒有 hreflang 更糟。 - 一個既有的 sitemap spec 斷言「一篇沒有日期的文章不該被捏造出 lastmod」,在每一篇文章都有了日期的那一刻變成一個假通過。所以它被改寫成自己建立 fixture。
第三點是這整份稽核裡我最喜歡的一個小東西:一個測試可以在完全沒有被修改的情況下停止測試任何東西。
十一、沒進前十、但同樣真實的
這些是同一次稽核的產物,一樣是真的,有幾個甚至比上面某幾則更適合當檢查項目。
Project.search查一個不存在的欄位,所以/projects上任何搜尋都 500。它的測試覆蓋率是零,而 commit 訊息把這件事直接寫成它會上線的原因。跟第五節那個 catch-all bug 是同一個 commit、同一天,但教訓正好相反:一個是測試在結構上碰不到,一個是單純沒測。這個專案在 2026-08-04 之前完全沒有 CI。 已經付掉的代價寫在 commit 裡:153 個 RuboCop offence 沒被注意到、測試套件四次跑有三次會卡住而沒有人知道、以及一個壞掉的 scope 上了正式環境。另外,CI 全程跑
RAILS_ENV=test,所以production.rb裡的一個 typo 要到部署那一刻才看得見。現在有一個獨立的 CI 步驟會實際啟動 production 環境,因為這個 repo 已經付過一次那個代價。一個 50% 的間歇性測試失敗率,追出來是 layout 從 CDN 載入 Bootstrap Icons 的 CSS,導致 headless Chrome 在 system spec 期間發出真實的網路請求。症狀表現成「找不到按鈕 X」,看起來像應用程式的 bug,把調查帶往完全錯誤的方向。順帶它也是一個「production 依賴第三方服務可達且夠快」的問題,而且 CDN 上釘的版本跟 package.json 裡的不一樣。
Devise 的
:recoverable開著,而整個專案沒有設定任何 SMTP。 這個站提供「忘記密碼」、接受表單、然後什麼都不做。以 7 個使用者計算,一個人忘記密碼就是 14% 的使用者被永久鎖在外面。修法是停止宣傳一扇打不開的門。一個
chown -R放在 Dockerfile 的COPY之後,導致每一個 commit 都產生一個 32.7MB 的重複 layer,因為 chown 會改寫 inode metadata,而 overlayfs 無法在不把每一個被碰到的檔案 copy-up 一層的情況下記錄這件事。用同一份乾淨 checkout 分別 build 前後兩個版本量出來:601,095,332 → 568,386,052 bytes,那個 layer 從 32.7MB → 8.9kB,而 COPY layer 是 byte-identical 的。看起來更簡單的COPY --chown被否決了,因為那會讓所有應用程式碼對 runtime user 可寫。一次死程式碼移除,可以當成「你怎麼知道它是死的」的教材。 要確認 Tag / Tagging 已經沒人用,搜尋必須排除掉
image_tag、stylesheet_link_tag、csrf_meta_tags、TagBuilder、一個bi bi-tag的 icon class、還有一個叫tagline的 i18n key。每一個命中都是假陽性。可逆性是這樣證明的:把 migration rollback 回去,然後把重新產生的schema.rb跟原始檔案做 byte-for-byte 的 diff。兩個 comment 的 view 裡有一段 inline 的 CKEditor 5 初始化腳本,而 CKEditor 沒有出現在任何 package.json、yarn.lock、importmap、Gemfile 或 vendor 目錄裡。它之所以從來沒有炸掉,只是因為它被包在
typeof CKEditor5 !== 'undefined'裡面。刪掉 23 行。一個值得寫進清單的 GNU make 陷阱:任何在文字上包含
$(MAKE)的 recipe 行,就算你跑的是make -n也會被實際執行。所以一個包含它的多指令 shell 區塊,會把一次 dry run 變成一次真正的部署。這個在這個 repo 裡實際驗證過,繞法是把 make 指到一個名字不同的變數。
十二、共同的形狀
把十個故障放在一起看,會浮出一個結構。
沒有一個是被「網站有沒有活著」抓到的。 每一個都在它自己的層面上產生了一個成功訊號:
| 故障 | 它產生的成功訊號 | 那個訊號實際回答的問題 |
|---|---|---|
| 部署跑的是舊程式碼 | Deployment complete!、容器健康、/up 200 |
「有東西在跑嗎」 |
| 圖片全掛 | /、/projects、/blogs 全部 200 |
「頁面回來了嗎」 |
make clean 會刪資料庫 |
Makefile 語法正確、CI 全綠、target 行為與程式碼一致 | 「它做了它寫的事嗎」 |
| log 上限沒管到 app | docker inspect 五個容器都有上限 |
「上限設定了嗎」 |
| 89,135 個 backtrace | 站是好的、機器人拿到的 500 沒人告警 | 「真人有回報問題嗎」 |
| 磁碟慢慢滿 | 每天看起來都一樣 | 「今天有壞掉嗎」 |
| 沒有備份 | runbook 存在、(若有 timer)list-timers 顯示健康 |
「排程裝了嗎」 |
| 1,778 筆垃圾資料 | 沒有指標、沒有 log、沒有 admin 頁面 | 什麼都沒問 |
| 1.39MB 的 CSS | build 成功、零警告、頁面正確渲染 | 「build 過了嗎」 |
| 預覽圖全滅 | 頁面 200、meta tag 存在、測試通過 | 「tag 在 HTML 裡嗎」 |
一句話:問題不是缺監控,是監控回答了一個相鄰的問題。
- 狀態碼回答「它活著嗎」,不回答「它是對的嗎」。
systemctl list-timers回答「它排程了嗎」,不回答「它成功了嗎」。docker inspect回答「上限設定了嗎」,不回答「這條串流歸它管嗎」。- Exit code 0 回答「指令跑完了嗎」,不回答「有東西改變了嗎」。
docker logs是空的,回答的是「沒有東西送到 driver」,讀起來卻像「沒事發生」。
所以下面那份清單裡的每一項,我都刻意用「你平常那個檢查沒有回答的那個問題」來寫。
第二條線:被證明會失敗的檢查
這次稽核裡有兩個修復是先證明它會失敗,才被信任的:
- smoke test 對著故意弄壞的憑證跑:5/5 失敗;換回來:5/5 通過。
- CI 的 image 斷言拿去對一個 alpine image 跑,確認它會 7/7 失敗而不是假通過。而第一個版本確實假通過了,因為
touch是因為路徑不存在而失敗,不是因為權限不足。
repo 自己的措辭是最好的版本:
一個沒被證明會失敗的檢查等於沒有檢查。
這件事在寫下面那份清單的時候又發生了一次,發生在我自己身上。第一版的圖片檢查是查 Content-Type,然後被我自建的 fixture 打臉:靜態伺服器是照副檔名決定 Content-Type 的,所以一個裝滿 HTML 的 .png 照樣回報 image/png,檢查會假通過。必須讀實際位元組的 magic number。
那正好就是這整篇文章的主題,在檢查本身身上又演了一遍。
十三、今晚可以跑的 14 項檢查
以下每一項都標了我有沒有實際跑過,跑出什麼。
三項跟 Docker 有關的沒有在本機執行(第 3、4、11 項),因為這台機器的擁有者要求 Docker 保持關閉。對那三項,我做的是:把每一個旗標和子指令對照官方文件確認、把外圈 shell 過 sh -n POSIX 語法檢查、並用「輸出結構跟 docker 相同的替身程式」實際跑過分類邏輯。標籤是 未在本機執行(已對 docs 核對指令形狀)。這比把一個沒人跑過的指令講成「已測試」誠實。
其餘 11 項都實際跑過,而且大多數同時驗證了「好」跟「壞」兩種結果與退出碼。
可攜性提醒:du 的 -x 和 -d 在 BSD(macOS)與 GNU(Linux)都可用,--max-depth 是 GNU 限定,不要用。systemctl 是 Linux 限定。macOS 的 df / 因為唯讀系統快照卷會嚴重低報可用空間,拿它當門檻會誤判。
1. 磁碟快滿了嗎?
df -h /
- 壞:使用率逼近滿載。但注意:這個指令只回答「還剩多少」,完全不回答「是誰吃掉的」,所以它永遠只是第一步。
- 好:使用率有明顯餘裕。而真正的門檻不是百分比,是「一次部署暫時需要的空間」是否還留得下來。先量出那個數字,再回頭看這個百分比夠不夠。
已實跑。 本機輸出 86% / 剩 2.3Gi。順帶驗證了那個 macOS 陷阱:在 macOS 上
/是唯讀系統快照卷,這個數字會嚴重低報。Linux 主機上兩者才是同一個。
2. 到底是什麼在吃磁碟?
du -x -m -d 1 / 2>/dev/null | sort -rn | head -12
- 壞:某個你講不出理由的目錄排在前面。特別是
/var。如果它很大而你不知道為什麼,這個指令就到此為止了,得往容器儲存區裡追(見下一項)。 - 好:最大的幾個目錄都是你認得、且解釋得通的。單位是 MB,由大到小排。
已實跑,兩種情境都跑了:完整的
du -x -m -d 1 /(在這台 228G 的 Mac 上約 7 分鐘才跑完,但在文章描述的那種 10G 等級小主機上是幾秒鐘的事),以及有界的子樹。
3. 最大的消耗者其實躲在 Docker volume 裡嗎?
docker system df -v
- 壞:某個你以為無關緊要的 volume(例如 log volume)排在最前面。這正是主機端
du看不到的死角:du只會告訴你/var很大,不會告訴你是哪一個 volume,更不會告訴你為什麼。 - 好:最下方的 Local Volumes 區塊逐一列出每個 volume 的名稱與大小,而且最大的那個是你預期的那個(例如資料庫)。
未在本機執行(已對 docs 核對指令形狀)。 已確認
-v/--verbose會輸出 Local Volumes 區塊,含每個 volume 的名稱、link 數與大小。不需要 docker 的等價寫法(Linux、需 root)是du -sm /var/lib/docker/volumes/* | sort -rn | head,其輸出結構我用替代目錄實跑驗證過。
4. 容器的 log 真的有上限嗎?而且真的有進到 log driver 嗎?
這一項是第四節那個故障的直接產物。
for c in $(docker ps --format '{{.Names}}'); do
printf '%-20s %-10s %-8s %s lines/24h\n' "$c" \
"$(docker inspect -f '{{.HostConfig.LogConfig.Type}}' $c)" \
"$(docker inspect -f '{{index .HostConfig.LogConfig.Config "max-size"}}' $c)" \
"$(docker logs --since 24h $c 2>&1 | wc -l)"
done
- 壞(兩種都要抓):
max-size欄位是空的。json-file driver 預設不輪替,這個容器可以一路把磁碟寫滿。- 更陰險的那種:
max-size看起來完全正確,但 24 小時 0 行,而這個服務明明在對外服務。上限沒有錯,錯在根本沒有東西送到 driver,應用程式自己寫檔去了。上限管的是一條空的串流。
- 好:每個容器都有
max-size值,而且行數都不是 0:有上限,也真的有東西流過去。
未在本機執行(已對 docs 核對指令形狀)。 已確認:
docker inspect -f '{{.HostConfig.LogConfig.Type}}'是官方文件明列的寫法;json-file 的max-size預設為-1(unlimited),而max-file只有在max-size有設時才生效,這正好佐證了「沒設上限就會撐爆」的前提。外圈 shell 通過sh -n,並用一支模仿 docker 輸出結構的替身程式實際跑過,三種容器狀態(正常、無上限、有上限但靜默)都正確分類。
5. 應用程式是不是自己把輸出寫進檔案,繞過了那個上限?
grep -rn 'stdout_redirect' config/ | grep -vE ':[[:space:]]*#'
- 壞:跑出一行把 stdout/stderr 導向 log 目錄下檔案的設定。這就是上一項那個「有上限卻 0 行」的成因,而且它在 code review 階段就抓得到,不必等到磁碟滿。
- 好:沒有結果。輸出走 stdout,容器的上限才管得到它。可以再確認一下 production 設定確實是 logger 指向
$stdout。
已實跑,而且是用真實的前後版本對照驗證的:在目前的 commit 上乾淨無結果;把同一個 repo checkout 回修好之前的那個 commit 再跑一次,就精準命中那一行
stdout_redirect。這是一個被證明會失敗的檢查。但這裡的退出碼要反過來讀。 這是 grep,所以命中(也就是故障存在)退出 0,沒命中(健康的那個情況)退出 1。兩邊我都實際量過。如果你在
set -e底下把它接進 CI 而沒有反轉判斷,你會在故障存在的時候剛好拿到綠燈。這整篇文章的論點,在一個四個字的指令裡又演了一次。
Rails 專案要找的是 puma 的 stdout_redirect;其他框架要找的是同一個形狀:任何把行程輸出接到檔案的設定。
6. 到底有沒有資料庫備份?最新一份夠新嗎?
ls -lt "$DIR"/*.sql.gz | head -5
n=$(find "$DIR" -maxdepth 1 -name '*.sql.gz' -mtime -2 2>/dev/null | wc -l)
echo "fresh backups: $n"; [ "$n" -gt 0 ]
- 壞(三種都要當成失敗):目錄不存在;目錄在但一個
.sql.gz也沒有(文件寫了排程、但從來沒真的裝上去);以及檔案有、但最新一份是好幾週前:排程還在,工作已經默默失敗很久了。 - 好:列得出檔案,而且第二個數字大於 0。另外要分開確認排程真的裝了:
systemctl list-timers(Linux)或crontab -l。
已實跑,四種狀態都驗過並確認退出碼:正常(0)、目錄不存在(1)、目錄空的(1)、以及用
touch回溯時間做出的過期備份(1)。第二行寫成一個判斷、而不是單純的
find | wc -l,理由很具體:wc是那條管線的最後一個指令,所以不管它數到幾,它都退出 0。 直接寫成裸管線的話,最該被抓到的「過期」那個情況會印出0然後回報成功。把數量明確拿出來比較,才讓退出碼有意義。
DIR 刻意留成必填參數,沒有寫死預設值,因為正式機的備份路徑不該印在文章裡。
7. 這份 dump 裡面真的有資料,還是一個看起來很像樣的空檔案?
gzip -t "$D" \
&& gzip -dc "$D" | tail -5 | grep -c 'PostgreSQL database dump complete' \
&& gzip -dc "$D" | awk '/^COPY .* FROM stdin;$/{c=1;next} c&&/^\\\.$/{c=0;next} c{n++}
END{print n+0; exit(n>0?0:1)}'
- 壞:最危險的是前兩項全過、列數卻是 0:gzip 合法、SQL 合法、
pg_dump完成標記也在,就是一列資料都沒有。 檔案大小完全騙得過你:實測有資料的那份 802 bytes、空的那份 785 bytes,差 17 bytes。用大小或「檔案存不存在」當檢查,這種備份會一路排進保留佇列。 - 好:gzip 完整、完成標記為 1、而且最後那個列數明顯大於 0。三個都過才算數。
已實跑,用真的
pg_dump(本機 PostgreSQL 17)產出三個 fixture 驗證:有列的 dump(3 列、退出 0)、schema 相同但無列的 dump(0 列、退出 1)、以及截斷的檔案(gzip 判為毀損、退出 1)。awk 的狀態機是逐 COPY 區塊數到\.為止,不依賴任何外部工具。
END區塊裡那個exit(n>0?0:1)是承重的,而且是量過之後才補上去的。沒有它,awk 會印出0然後退出0,也就是這一項存在的唯一理由(那個很有說服力的空 dump)會回報成功。一個把壞消息印出來、然後回答「沒事」的檢查,正是這整篇文章在講的那個失敗模式。
8. 有人真的還原過一份備份嗎?
trap 'dropdb "$S" 2>/dev/null' EXIT INT TERM
createdb "$S" || exit 1
gzip -dc "$D" | psql -q -v ON_ERROR_STOP=1 -d "$S" >/dev/null || exit 1
n=$(psql -Atd "$S" -c "select coalesce(sum(cnt),0) from (
select (xpath('/row/c/text()', query_to_xml(
format('select count(*) as c from %I.%I', schemaname, tablename),
false, true, '')))[1]::text::bigint cnt
from pg_tables where schemaname='public') t")
echo "restored rows: $n"
[ "$n" -gt 0 ] || exit 1
- 壞:還原成功、總列數 0。這跟上一項是不同的失敗:那一項檢查檔案,這一項檢查「這個檔案放回資料庫之後還剩下什麼」。
ON_ERROR_STOP=1是關鍵。沒有它,psql會吞掉錯誤然後以 0 退出,你會得到一個殘缺的資料庫和一份成功的回報。 - 好:還原過程沒有錯誤,最後印出的總列數大於 0。這是唯一能把「備份」從假設變成事實的一步。
已實跑,兩種 fixture 都驗過:有資料的還原出 3 列(退出 0),空的還原出 0 列(退出 1)。
trap ... EXIT INT TERM讓臨時資料庫在每一條離開路徑上都被清掉,包含 Ctrl-C;跑完後查pg_database確認 0 筆殘留。跟上一項同一個陷阱,只是高一層。如果寫成單純的
&&串接、最後一個指令是dropdb,整段的退出碼就是dropdb的退出碼,於是一次還原出零列的執行,甚至一次你根本沒去看列數的執行,都會退出 0。把列數接進變數再拿來判斷,就是「一個檢查」跟「一段輸出」的差別。
務必還原到一個丟棄用的新資料庫,絕對不要對著正式庫跑。 伺服器上若資料庫在容器裡,同樣的形狀是把 gzip -dc 的輸出用管線送進 docker compose exec -T db psql。
9. 有沒有哪個破壞性指令是手滑就會踩到的?
awk '/^[A-Za-z0-9_.-]+:/{split($0,a,":");t=a[1]}
/--volumes|volume[ \t]+(rm|prune)|rm -rf|db:drop|db:reset/{
if($0!~/^[ \t]*#/) printf "%s (line %d): %s\n", t, NR, $0 }' Makefile
- 壞:破壞性的那一行掛在一個聽起來人畜無害的 target 底下(
clean、reset、tidy之類)。這才是真正的風險:磁碟不夠的半夜,操作的人掃過make help找一個能清空間的東西,然後刪掉正式資料。名字必須跟後果一致。 - 好:要嘛沒有結果,要嘛命中的行都落在一個名字就在喊「我會刪東西」的 target 底下,而且前面還有需要打字確認的關卡。
已實跑,而且用真實的前後版本對照證明它有效:對修好之前的 Makefile 跑,輸出
clean (line 152);對現在的 Makefile 跑,同一行程式碼出現在destroy-all-data (line 383)。
同一個指令、同一行程式碼,差別只在它掛在誰底下。這個檢查問的不是「哪裡有危險指令」。它問的是「危險指令蹲在哪個名字後面」,而後者才是會不會被誤觸的關鍵。(腳本也可以吃 scripts/ 底下的檔案。)
10. 這個 checkout 真的在你以為的那個 commit 上嗎?
git fetch -q
L=$(git rev-parse HEAD); R=$(git rev-parse @{u})
[ "$L" = "$R" ] && echo "ok: $L" || echo "BEHIND by $(git rev-list --count HEAD..@{u})"
- 壞:落後 N 個 commit。這是最便宜也最常見的無聲空部署:
git pull因為認證失敗而沒有真的更新,但後面每個步驟照跑、照樣回報成功,把舊程式碼重新部署一遍。 - 好:兩個 SHA 相同。另外順手看一下
git status --porcelain是不是空的。有未提交的改動,代表跑著的東西不對應任何一個 commit。
已實跑,正反兩面都驗過:對這個 repo 跑是 ok;另外把 repo clone 到暫存區、reset 回退三個 commit 再跑,正確印出
BEHIND by 3並以 1 退出,再加一筆未提交改動也正確額外警告。全程在 clone 上操作。
11. 跑在線上的那個 artefact,真的是你要的那個 commit 建出來的嗎?
intended=$(git rev-parse HEAD)
running=$(docker container inspect --format '{{.Config.Image}}' "$C")
echo "intended $intended"; echo "running $running"
[ "${running##*:}" = "$intended" ] && echo MATCH || echo MISMATCH
- 壞(兩種):
- MISMATCH:跑著的 image 是別的 commit 建的。每一步都綠燈,但新程式碼沒上去。
- tag 是
:latest:那代表這件事根本無從查起,因為浮動 tag 不承諾任何 commit。要讓這個檢查成立,部署就得釘在不可變的 tag(commit SHA)上。
- 好:running 的 tag 就是 intended 的 commit SHA。這是唯一能把「部署成功」跟「部署了新程式碼」分開的檢查。
未在本機執行(已對 docs 核對指令形狀)。
docker container inspect的--format已對官方文件確認,文件亦提及Config.Image為可取用欄位;.Config.Image取的是「當初請求的 image 參照(repo:tag)」,正是要拿來跟 commit 比對的東西(.Image取的是 image ID,不適用)。外圈 shell 通過sh -n,並用三支輸出結構與 docker 相同的替身分別驗過 match、stale、floating:latest三種情況,退出碼皆正確。
12. 網站是真的能用,還是只是回 200?
這是清單裡最重要的一項。
origin=$(printf '%s' "$PAGE" | sed -nE 's#^(https?://[^/]+).*#\1#p')
curl -sSL "$PAGE" | tr '<' '\n' | grep -i '^img ' \
| sed -nE 's/.*src="([^"]+)".*/\1/p' \
| sed -e 's#^//#https://#' -e "s#^/#$origin/#" | head -5 \
| while read u; do
b=$(mktemp)
c=$(curl -sSL -o "$b" -w '%{http_code}' "$u")
echo "$c $(wc -c <"$b") $(dd if="$b" bs=1 count=4 2>/dev/null | od -An -tx1) $u"
rm -f "$b"
done
- 壞:頁面本身 200,但某個
<img>追到底之後拿回來的開頭是3c 68 74 6d(<htm),那是一張錯誤頁穿著.png的名字。這就是第二節那一類故障:每頁都回 200、每張使用者上傳的圖都是壞的。頁面沒問題,壞的是頁面裡面的東西,只查狀態碼的檢查永遠看不到。 - 好:每一列都是 200、位元組數合理,而且開頭的 magic number 是真的圖片格式:
89504e47是 PNG、ffd8ff是 JPEG、47494638是 GIF、52494646是 WebP。
已實跑。 這也是唯一一個我在驗證過程中改掉自己寫法的:第一版檢查
Content-Type,結果被自建 fixture 打臉:靜態伺服器是照副檔名決定Content-Type的,一個裝滿 HTML 的.png照樣回報image/png,檢查會假通過。所以必須讀實際位元組的 magic number。驗證方式:本機起一個 fixture 站,頁面回 200 而其中一張圖是偽裝成
.png的錯誤頁,檢查正確抓到並以 1 退出;修好那張圖後同一頁通過並以 0 退出;再對真實公開網站跑過確認在真實 HTML 上可用。那行
origin是因為 fixture 抓到的第二件事才加的。真實頁面大多寫成src="/path/img.png",而 curl 會直接拒絕這種值("no host part in the URL"),所以早一版對頁面上每一張完全健康的圖都印出000。一個什麼都判失敗的檢查,跟一個什麼都判通過的檢查一樣沒用;把根相對路徑接回頁面自己的 origin,才讓它在真實網站上跑得動。
提醒:合法但極小的圖(追蹤像素)不該算失敗,我把它降為警告,否則這個檢查會浪費你一整個晚上。
13. 分享出去的預覽圖標籤,是絕對網址而且真的抓得到嗎?
v=$(curl -sSL "$PAGE" | tr '<' '\n' \
| grep -iE '^meta[^>]*property="og:image"' \
| sed -nE 's/.*content="([^"]*)".*/\1/p' | head -1)
case "$v" in
https://*|http://*) echo "absolute: $v" ;;
*) echo "RELATIVE (broken): $v" ;;
esac
curl -sSL -o /tmp/og -w 'http %{http_code} bytes %{size_download}\n' "$v"
dd if=/tmp/og bs=1 count=4 2>/dev/null | od -An -tx1
- 壞:相對路徑。它在瀏覽器裡看起來完全正常、不會有任何錯誤,但 Open Graph 要求絕對網址,所以每一個社群平台都給不出預覽圖。另一種是絕對網址但 404,那張當作 fallback 的圖其實從來不存在。兩種都不會有任何東西報錯。
- 好:是
https://開頭的絕對網址、回 200、而且 magic number 是真的圖片。twitter:image與og:url用同樣方式各查一次。
已實跑,正反都驗過:自建 fixture 用相對路徑(正確判為 RELATIVE、退出 1),改成絕對網址且圖片存在則通過;再對一個真實公開網站跑過,正確取出絕對網址、200、36,471 bytes、magic
89504e47(PNG)。
分開檢查 fallback 和單頁的路徑。它們是獨立失敗的,而 fallback 覆蓋你大部分的頁面。另外值得知道的是為什麼「相對路徑 + summary_large_image」特別慘:那個版型幾乎整張都是圖,空的時候比不設卡片還難看。
14. 樣式檔是不是比它該有的大很多?裡面到底裝了什麼?
f=app/assets/builds/application.css
echo "total $(wc -c <$f) | gzip $(gzip -c $f | wc -c) | inline sourcemap $(tr -d '\n' <$f | grep -o 'sourceMappingURL=data:[^*]*' | wc -c)"
- 壞:inline sourcemap 佔掉檔案一大半。那不是 CSS,是建置工具內嵌的 base64 地圖,而每個訪客在首次繪製前都得把它下載完。先量再砍:不知道大的是什麼就去做 tree-shaking,風險高得多,因為樣式壞掉不會報錯,只會安靜地變醜。
- 好:inline sourcemap 為 0,而且 gzip 後的大小跟你對「這個站有多少樣式」的直覺對得上。訪客實際下載的是 gzip 那個數字。
已實跑,正反都驗過。目前的建置產物:472,411 bytes、gzip 61,844、inline sourcemap 0。這兩個數字與 commit 訊息記載的修復後數值完全吻合,可回溯。壞的情境用「目前的 CSS + 一段內嵌 base64 sourcemap」重建出來,偵測器回報 1,389,802 bytes、66% 不是 CSS,與真實修復前的 1,393,531 bytes / 65.9% 幾乎一致。
重建檔的 gzip 數字不可引用(隨機 base64 不像真實 sourcemap 那樣可壓縮)。真實的修復前後 gzip 是 183,803 → 61,844。
十四、還沒修好的、以及刻意不修的
如果這篇只講修好的東西,它就是一篇宣傳文。所以這一節。
刻意延後的:
- Bootstrap tree-shaking。 CSS 現在是 472,411 bytes(gzip 61,844),還是比它該有的大。沒做的理由寫在 commit 裡:壞掉的樣式不會報錯,它只會安靜地看起來不對,而在一個低流量的站上那可以好幾個禮拜沒人發現。先做那個零風險的
--no-map,把量大的那 66% 拿掉;剩下的等有人真的量過視覺回歸再說。 docker image prune -a沒有被放進clean。因為-a會在 stack 剛好停著的時候刪掉當前的 app image,逼一台撐不起 build 的機器做完整重建。這是一個刻意選擇的、不那麼徹底的清理。
知道但還沒做的:
- repo 裡有四個檔案還寫著那個錯誤的「1,778 筆全部一樣」的結論(見第八節)。正確的數字在 rake task 和那則更正的 commit 裡。我把這件事寫進文章而不是先偷偷修掉,因為它本身就是這篇的論點:一個在被查證之前就寫下來的發現,會散佈到四個地方去,而沒有任何自動檢查會發現一句註解是錯的。
- 部署時的檢查不等於告警,而這次稽核也沒有把它變成告警。 現在有的是 preflight 門檻(會擋掉一個空間不夠完成的操作)和部署後印出的
df,這都是實際的改善,但它們都是在你部署的時候才會說話。針對緩慢變數的持續性告警是另一件有自己設計問題的工作,而第六節就是「為什麼該做」的論證:那一類故障會安靜地劣化好幾個月,才第一次做出你看得見的事。 - 「被驗證過的備份」和「撐得住事故的備份」是兩個不同的問題。 從「完全沒有」走到「有排程、有驗證、還原得回來」,是這次稽核做掉的那一步,也是把假設變成事實的那一步。至於副本有幾份、彼此隔多遠,是下一個問題,而一份每晚都跑得好好的 dump 一個字都沒有回答它。別讓前者說服你已經解決了後者。
兩個關於稽核本身的誠實註記:
- 那 127 次
Errno::ENOSPC是這份清單裡來源最弱的數字。它出現在三個地方,但三處都是同一個人事後的記錄,repo 裡沒有留下 log artefact。月份精度,就這樣。 - 「89,135 份 backtrace 撐爆了磁碟」是推論,不是量測。 兩個獨立來源分別說了「那些 backtrace 是磁碟被撐爆的主因」和「那個 log volume 到了 965MB」,兩者幾乎確定是同一批 bytes,但沒有任何一個來源直接這樣寫。
一句話總結
這十個故障沒有一個是被「網站有沒有活著」抓到的,因為網站一直都活著。
它們全部活在同一道縫裡:「有東西在跑」和「跑的是對的東西」之間。 而幾乎所有現成的檢查(狀態碼、健康檢查、exit code、list-timers、docker inspect)量的都是縫的左邊那一半。
所以最實用的一條規則是:對你依賴的每一個檢查,問一次「它實際回答的是哪個問題」,然後問「我真正想問的是哪一個」。 這兩個問題不一樣的地方,就是你的故障住的地方。
還有那句 repo 自己寫的、我到現在還在用的:
一個沒被證明會失敗的檢查等於沒有檢查。
今晚挑上面清單裡的一項,然後故意把它弄壞,確認它會叫。
本文所有數字都來自這台正式機的 git 歷史、程式碼註解,或是我在 2026 年 8 月 4 日至 8 月 6 日之間實際量到的輸出。有兩個數字我在寫這篇的時候重新獨立量過一次,跟 commit 訊息記載的完全吻合(CSS 的 472,411 / 61,844 bytes,以及那張 1200x630 的 PNG)。推論的地方我都標了「這是推論」。四項與 Docker 相關的檢查沒有在本機執行,標籤是「已對 docs 核對指令形狀」,因為那比把一個沒人跑過的指令講成「已測試」誠實。
基於安全考量,本文刻意不包含:速率限制的門檻與時間窗、垃圾內容的判定特徵、蜜罐欄位名稱、kill switch 的變數名、憑證檔路徑、registry 命名空間、部署帳號、checkout 路徑,以及伺服器 IP。每一道防線我只寫形狀和推理。