前一篇文章中,我記錄了 iOS(UIKit)+ FastAPI + Kubernetes 做出的 Minecraft 伺服器監控應用「MineWatch」,如何在 3 次被退回後通過 App 審查。MineWatch 的官方網站在這裡。
即使通過審查之後,在 CI/CD 相關流程上,我還是發生了 2 起生產環境事故。兩次都是 CI 綠燈通過,事前也沒有任何機制能夠察覺。第一次是因為 Firebase Auth 無法登入才發現;第二次則是 ArgoCD 的異常通知觸發了警覺。這篇文章整理了這兩起事故為什麼會發生、我是怎麼發現的,以及其他專案也可能重現的技術陷阱。
make 的 fallback 設計,沒有報錯就被提交到 App Store,導致正式環境中的 Firebase 在未初始化狀態下,登入全面失效。docker compose 的 project 名稱解析規則。MineWatch 的 iOS 端,將 Firebase 設定檔(GoogleService-Info.plist)分成 Debug / Release 兩份管理。兩者都屬於秘密檔案,因此有加入 .gitignore。
swift-init: $(GSI_DEBUG) $(GSI_RELEASE) ## 從 settings.env + project.yml 產生 Xcode 專案
這個 Make target 裡有一個以開發體驗為優先的設計決策:當真實檔案不存在時,不報錯,而是默默以預設檔案(.sample)代替,讓建置繼續。在剛 clone 到新機器時,或開發途中暫時把檔案移走時,不需要動不動就卡住,建置也能順利通過。對開發來說,這沒有任何問題。
問題在於,這個相同的 fallback 也作用在產生 App Store 提交物的最後一個命令上。如果在沒有把 Release 用的 Firebase 設定換成真實檔案的情況下執行 make archive,建置依然會正常結束。由於預設檔案中的 apiKey 等資料實際上無法完成 Firebase SDK 初始化,因此提交出去的版本會在正式環境中變成登入與 App Check 全面失效的狀態。
我是透過正式版 App 的 Firebase Auth 登入失敗才發現這件事的。自己的裝置上會顯示「驗證失敗」而無法登入,請朋友幫忙測試後,在另一台獨立裝置上也出現了相同現象。從建置到提交,CI 和本機命令都一路正常結束,直到這個時間點為止都沒有人停下來。
開發時的 fallback 保持不變,只在 archive 之前額外加入一個檢查預設檔案的 gate。順帶一提,Makefile 的 recipe 行實際上必須用 tab 做縮排。以下為了排版方便改成了空格。
.PHONY: check-firebase-release
check-firebase-release: ## 確認 Release 用 Firebase 設定不是預設檔案
@if [ ! -f $(GSI_RELEASE) ]; then \
echo "error: $(GSI_RELEASE) 不存在。請先執行 make swift-init。"; \
exit 1; \
fi
@if diff -q $(GSI_RELEASE) $(GSI_SAMPLE) >/dev/null 2>&1; then \
echo "error: $(GSI_RELEASE) 仍是預設檔案(與 GoogleService-Info.sample.plist 內容相同)。"; \
echo " 請先放入真正的 Firebase 設定檔再重試。"; \
exit 1; \
fi
.PHONY: archive
archive: $(XCODEPROJ) check-firebase-release ## 為 TestFlight 進行 Archive(Release + Apple Distribution)
...
重點不只是檔案有沒有存在,還要用 diff 確認它的內容是否真的不等於範例檔。因為「檔案存在」只代表有個東西放在那裡,並不能排除只是把預設檔複製了一份。
「不存在就用替代品繼續」這個設計本身沒有錯。開發時重視便利性,發行前重視安全性,兩者需求發生的時點本來就不同。把同一種輸入檢查,依不同階段切換成「警告後繼續」與「立即停止」,這是個理所當然、卻很容易被忽略的教訓。
這一段比較複雜,依時間順序說明。
trivy image scan 找出的作業系統套件 CVE。這是一個會同時修改 api / web / admin 三個 Dockerfile 的變更,而且是在最初那個判斷之後才追加進來的修改。health: Degraded 通知。追查後發現,生產環境中的容器映像檔仍停留在修補前的標籤,CVE 修補雖然存在於 git,卻完全沒有反映到生產環境。MineWatch 的生產環境映像檔標籤,是手動寫在 Kubernetes overlay(kustomization.yaml)裡管理的。
images:
# 生產環境標籤是這裡唯一的真實來源(不可變動標籤 <branch>-<SHA12>,
# 由建置管線 push)。部署流程 = 在 release / hotfix 分支把這裡
# 改成實際標籤 → 合併到 main。管線不會直接對生產環境 set image。
- name: registry.example.com/minewatch/api
newTag: release-1.2.1-<SHA12>
「只要改 git 這裡,生產環境就會跟著更新」這種流程,反過來說也代表「即使忘記改,系統也不會自動提醒」。CI 會通過,其他 PR 也能照常合併,沒有人會發現。
ARG 只是宣告並不會自動生效CVE 修補本身(用 apt-get upgrade / apk upgrade 更新作業系統套件)是用一個每次建置都不同的值透過 ARG 傳入,以便讓快取失效,這是很常見的做法。
ARG CACHEBUST=dev
RUN apt-get update && apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
但這招完全沒用。第一次在本機建置時,確實把 CVE 修掉了;可是在第二次之後的建置裡,舊套件的快取仍然持續被重用。
原因在於,Kaniko(以及 Docker)層級快取的 key 是「展開變數之後的 RUN 字串本身」。即使宣告了 ARG,如果沒有在 RUN 裡實際參照那個變數,RUN 字串本身每次都一樣(apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*)。既然快取 key 沒變,不管 CACHEBUST 的值怎麼改,都會直接被略過。
修正方式很單純,只要讓 RUN 裡真的展開這個變數即可。
ARG CACHEBUST=dev
RUN echo "cachebust: ${CACHEBUST}" && apt-get update && apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
只要插入一行 echo,RUN 字串就會包含 ${CACHEBUST} 展開後的值;每次值改變,快取 key 就跟著變,該層也會重新建立。雖然不起眼,但只要是拿 ARG 來做快取失效,這一點一定要確認。
把 CACHEBUST 修正之後,我在本機跑 docker compose build → trivy image scan,確認 CVE 變成 0 件,於是以為問題已經解掉了。沒想到跨過正式發布後,同一個 CVE 還是會被報出來,讓我一直無法重現真正的狀況。
原因是 docker compose 有一個規則:如果沒有明確指定 project 名稱,它會使用當前目錄名稱。MineWatch 在每次釋出時,都會用 git worktree 切出另一個目錄來作業,因此每次建置出的映像檔名稱都會變成像 <目錄名稱>-api 這樣,隨著作業目錄而改變。
但 trivy 掃描的目標名稱卻是固定寫死的(minewatch-api),因此依 CI 或本機當下的情況不同,它有時其實掃到的是先前某次作業中建置過、但完全無關的舊映像檔,然後誤報「CVE 0 件」。本機顯示綠燈,但實際上根本沒有驗證到正確的東西。
# 固定 project 名稱。不指定時 docker compose 會把當前目錄名稱
# 當作 project,因此映像檔名稱會變成 `<dir>-<service>`(例如:
# 不是 `minewatch-admin`,而是 `fix-trivy-admin-admin`)。
# 在 worktree 運作模式下(目錄名稱會隨分支改變),Makefile 裡的
# TRIVY_IMAGES(固定名稱如 `minewatch-admin`)會和建置後的實際映像檔名
# 不一致,導致 trivy-scan 持續誤掃到舊的另一個映像檔。
name: minewatch
services:
db:
...
只要在 docker-compose.yml 最上層固定 name: 就能解決,但在注意到這件事之前,最難切分的情況就是「本機看起來已經修好了,為什麼只有正式環境沒修好」。
事後回頭看,事故 1 與事故 2 雖然發生在完全不同的技術領域(iOS 建置設定與 Kubernetes 部署流程),但形態非常相似。
這兩種情況都不是 CI 或測試能直接抓到的失敗。CI 驗證的是「寫下來的程式是否依照規格運作」,但「該送達正式環境的東西是否真的有送到」是另一個層次的問題。
為了避免再發生,我現在在檢查清單與機制兩方面都加入了以下兩點:
check-firebase-release)繼「做出 App」和「讓 App 通過審查」之後,這次我也在「讓 App 持續運作」這件事上學到了很多。對個人開發者來說,這類基礎建設與 CI/CD 的陷阱,常常都是親自踩過一次之後,才真的理解那些規格細節。Kaniko 的快取鍵規則、docker compose 的 project 名稱解析規則,官方文件其實都有寫,但在真正出事之前,我都沒把它們當成跟自己有關的事情。
MineWatch 現在仍然由我個人持續維運中。下次如果再踩到什麼大事故,我也會再寫成文章。
JQIT 的工程師有 95% 以上是未經驗錄用。
如果可以,也歡迎來我們的企業網站逛逛。
可以從零開始學習!一起挑戰吧!
我們也有 note 和 X ↓
原文出處:https://qiita.com/jqit-yukiono/items/21973aaa1c605b34c174