事件時間線:4/19 遷移 → 4/27 renew → 5/5 才發現
- 4/19:web server 從 nginx 切換到 IIS。原 nginx-era 的
scripts/post-renew.ps1沒同步重寫,仍含Restart-Service nginx邏輯。Scheduled TaskSSL-PostRenew每天 06:00 跑,每天 exit 1 失敗,但沒人看 LastResult。 - 4/27 08:32:Let's Encrypt 自動 renew,新 cert 進 LocalMachine\My,win-acme 用 IIS install plugin 把 cert 綁定到 IIS site binding。但 cert ↔ private key 的 schannel metadata(KeyProvInfo)沒設好——cert 安裝成功,private key 在 LocalMachine\Crypto\RSA 內,但 schannel TLS handshake 找不到對應 key file。
- 4/27 之後 8 天:所有 HTTPS connection TLS handshake reset。Cloudflare cache 部分頁面繼續 serve(看起來像「偶爾壞」),origin 直連完全壞。
- 5/5 18:42:使用者體感到網站進不去回報。19:30 修好——
certutil -repairstore My <thumbprint>重建 cert ↔ key metadata 關聯,schannel 立即生效,IIS 不需重啟。
為什麼 8 天才被發現?三道警戒線同時失效
事件後做 postmortem,列每一道應該攔下的機制:
- 沒有外部 uptime monitoring。沒有任何 service 從外網每幾分鐘 ping 首頁。
- Sentry 監測 SDK 載不到。HTTPS handshake 階段就 reset,瀏覽器根本沒下載 JS 檔案,error tracking SDK 從未啟動。「網站壞了 Sentry 會通知」這條假設依賴 SDK 至少能載入。
- 開發人員 hosts override 直接訪問 origin。dev 機器把
www.kmau.com.tw指向內網 IP,origin 沒裝對外的 cert,所以 dev 自己訪問本來就 https 失敗。「真壞」跟「平常 hosts 失敗」沒分別。 - Cloudflare cache 部分 stale 繼續 serve。某些 user 看到舊頁面,不一定意識到網站已壞。Cache hit 反而蓋住了完全失效的事實。
- Scheduled Task LastResult 純背景。SSL-PostRenew 每天 exit 1,但沒 alert pipeline,沒人定期看 schtasks。
每一道都是「正常情況下會發現」的機制,但都依賴前一道工作。一旦多道同時失效,故障靜默降級的時間 = MTTR。
Root cause:cert install ≠ schannel ready
真正 root cause 不是 nginx 殘留 script——那是觸發點,不是真原因。真原因是:
win-acme 的 IIS install plugin 把 cert 安裝到 LocalMachine\My,更新 IIS site binding,但沒可靠地設定 schannel 需要的 KeyProvInfo metadata(PROV_INFO 結構)。
cert 跟 private key 在憑證儲存區看起來都在,但 schannel 找不到 cert 對應的 key file。對 IIS 來說 binding 看起來正常,對 schannel TLS 處理來說整個 handshake 用不出 key 來簽——直接 RST 連線。
certutil -repairstore 工具重建這個 metadata,把 cert thumbprint 跟 RSA key file 在 KeyProvInfo 上關聯起來。修完後 schannel 立即可用,不需要重啟 IIS。
三層防護方案(彼此獨立、互為 backup)
Layer 1:外部 uptime monitoring(5 分鐘 alert)
用 UptimeRobot / Pingdom / Better Uptime 等外部服務每 5 分鐘對首頁做 HTTPS HEAD。任何 5xx / TLS handshake reset / connection refused 立即 email + 推播。
關鍵特性:服務在外網、不依賴自家網站任何元件——即使整個 origin 都炸了也照常 alert。免費版即可,不需要付費版的「cert expiry 提前 30 天通知」(HTTPS check 在 cert 到期當天 5 分鐘內也會 alert,本來就 cover)。
從 8 天 → 5 分鐘,這一道就把事件影響範圍縮 99.96%。
Layer 2:主機端 hourly self-healing
Scheduled Task 每小時跑 idempotent 腳本:
- 找
LocalMachine\My內最新(NotAfter 最遠)的 cert - probe RSA private key accessibility(嘗試讀 KeyProvInfo)
- 不通就
certutil -repairstore My <thumbprint> - 用
netsh http show sslcert驗證 IIS binding(避免 hairpin NAT 直接做 outbound HTTPS) - 失敗就 POST 到
/api/error-report→ Sentry alert
worst case 從 24 小時降到 1 小時。注意是 idempotent——成功狀態下重跑 100 次也不副作用。
Layer 3:ACME post-renew Script hook(root cause fix)
真正解決問題的不是 mitigation,是從根源消除 metadata 不一致。win-acme 的 renewal config 加 Script install plugin(與 IIS install plugin 並列):
{
"InstallationPluginOptions": [
{ "Plugin": "iis", ... },
{ "Plugin": "script",
"Script": "C:\\inetpub\\kmauWeb\\scripts\\post-renew.ps1",
"ScriptParameters": "-Thumbprint {CertThumbprint}" }
]
}
renew 後 win-acme 會依序:(1)IIS plugin 安裝 cert + 更新 binding、(2)Script plugin 立刻跑 post-renew.ps1 修 metadata。0 秒延遲,不靠 hourly safety net。
這層才是 root fix——下次 cert renew 完成那一刻 metadata 就對了。Layer 1 跟 Layer 2 變成「萬一 Layer 3 也壞了」的多重保險。
Mitigation vs Root fix:差別在哪?
事件當天為了讓網站立刻好,跑 certutil -repairstore 是 mitigation——治了當下,沒解未來。下次 renew 還會壞。
真 root fix 是讓 renew 的那一刻就修好,不靠 hourly poll。差別:
| 方案 | 修復延遲 | 覆蓋未來 | 類型 |
|---|---|---|---|
| 事件當下 certutil -repairstore | 0(只當下) | 不 | Mitigation |
| Layer 2 hourly self-healing | ≤ 1h | 是 | Safety net |
| Layer 3 win-acme Script hook | 0 | 是 | Root fix |
| Layer 1 UptimeRobot | 5 min(alert,不修) | 是 | Detection |
三層各司其職:Detection(你發現) + Self-healing(系統自修) + Root fix(從源頭不再壞)。任何一層失效,其他兩層仍 cover。
架構遷移後的審查 SOP
事件追到底,根因是「nginx → IIS 遷移時沒清舊架構的 Scheduled Task / script」。post-renew.ps1 從遷移那天起 16 天每天都失敗,但失敗對 happy-path(cert 沒 renew)沒影響。直到 4/27 renew 觸發新路徑才壞。
未來架構切換的審查 SOP:
- 列出舊架構所有 Scheduled Task / cron / hook script(一個都不能漏)
- 逐個評估:刪除 / 重寫 / 保留(含解釋為何保留)
- 砍 service 同時砍 file(如
C:\nginx\直接 rename 成.deprecated.YYYYMMDD) - 切換後 prod 至少跑一次真實的 hot path(如手動 renew 一張 cert),不只看 happy path
- Postmortem 寫進 runbook,下次同類遷移直接照單檢查
給其他企業 IT 主管的 5 個 takeaway
重點摘要
- 外部 uptime monitoring 不選——任何企業網站都該有。免費版每 5 分鐘 HTTPS check 就把 MTTR 從天級降到分級,沒理由不裝。
- Sentry / 自家 monitoring 不能當唯一警戒線——它依賴你的網站還能載 JS。HTTPS handshake 階段壞了,Sentry 不會通知你。
- 「自動化」不等於「不會壞」——LE 自動續期是過程自動化,不保證結果正確。每個自動化都要有 fail-safe + alert。
- 架構遷移必審 legacy script——切換 web server / DB / framework 時,舊架構的 cron / Scheduled Task 通常被忘記。「砍 service 順手砍 script」是流程紀律。
- Mitigation 是急救,不是治療——事件後一定要追到 root cause,否則下次同樣方式會再壞。
需要協助設計企業網站的 SSL / TLS 監測、ACME post-renew hook、或事件後 postmortem 流程?凱茂提供包含 Layer 1-3 完整方案的網站維運諮詢。
預約 30 分鐘免費網站維運架構諮詢 →常見問題
Let's Encrypt 自動續期成功,為什麼網站還會壞?
ACME 客戶端(如 win-acme、Certbot)會把新 cert 安裝到憑證儲存區並更新 IIS binding,但 Windows schannel 需要 cert 跟 RSA private key 在 metadata 上正確關聯(KeyProvInfo)。某些情境下 install 完成但 metadata 沒設好,IIS 拿到新 cert 卻無法用 private key 簽 TLS handshake——使用者連線立刻被 reset,但 ACME 客戶端 log 顯示 success。這是 LE 自動續期最隱蔽的失敗模式。
為什麼 8 天都沒人發現?
三個原因同時失效:(1)沒外部 uptime monitoring,沒人從外網每幾分鐘 ping。(2)Sentry 監測 SDK 載不到(HTTPS handshake reset 階段就斷),所以連 Sentry 都沒收到 event。(3)開發人員 hosts override 直接 IP 訪問 origin,本來就 https 失敗,所以「真壞」沒分別。每一道都是「正常情況下會發現」的機制,但都依賴前一道工作。8 天才被使用者直接體感到。
為什麼 hourly self-healing 不夠?要 0 秒延遲?
Hourly self-healing 是 mitigation 不是 root cause fix——它把壞掉時間從 24 小時降到 1 小時,但仍然有壞掉時間。真正 root cause fix 是 ACME 客戶端的 install plugin chain:cert install 完立即跑 post-renew script 修 metadata。win-acme 設定加 Script install plugin(IIS plugin 之外),renew 後 0 秒延遲修好。三層防護 = root fix + hourly safety net + external monitoring。
certutil -repairstore 修了什麼?
certutil -repairstore My <thumbprint> 重建 cert ↔ RSA private key 的 KeyProvInfo metadata(PROV_INFO 結構),把 cert 跟 LocalMachine\Crypto\RSA 內的 private key file 關聯起來。對 schannel 來說相當於告訴它「這個 cert 用這個 key 簽」。修復後 IIS 不需重啟、TLS handshake 立即可用。注意需要 admin 權限。
外部監測一定要付費版的 cert expiry 通知嗎?
不需要。免費版 5 分鐘 HTTPS HEAD 檢查在 cert 到期當天 / TLS handshake reset 5 分鐘內就會 alert——cert 到期後 HTTPS check 必失敗。付費版的 cert expiry 提前通知(如 30 / 14 / 7 天前)對「忘記 renew」場景有用,但對「自動續期成功但 install 失敗」這種主流故障模式,免費版 HTTPS check 已足夠。先用免費版開始,有預算再升級。
訂閱 IT 技術電子報
每月精選 IT 趨勢與實務文章,直接送到你的信箱