個人開發中學習 App Store 發行流程的三部曲 ― 只有生產環境靜靜壞掉的 CI/CD 兩起事故

前一篇文章中,我記錄了 iOS(UIKit)+ FastAPI + Kubernetes 做出的 Minecraft 伺服器監控應用「MineWatch」,如何在 3 次被退回後通過 App 審查。MineWatch 的官方網站在這裡。

即使通過審查之後,在 CI/CD 相關流程上,我還是發生了 2 起生產環境事故。兩次都是 CI 綠燈通過,事前也沒有任何機制能夠察覺。第一次是因為 Firebase Auth 無法登入才發現;第二次則是 ArgoCD 的異常通知觸發了警覺。這篇文章整理了這兩起事故為什麼會發生、我是怎麼發現的,以及其他專案也可能重現的技術陷阱。

TL;DR

  • 事故 1:Release 用的秘密檔案缺少實體內容,卻因為 make 的 fallback 設計,沒有報錯就被提交到 App Store,導致正式環境中的 Firebase 在未初始化狀態下,登入全面失效。
  • 事故 2:最初「這次沒有新增功能,所以不需要重建正式環境映像檔」的判斷,後來一路延續到新加上的 CVE 修補 commit,結果出現了修補已存在於 git,但沒有送達正式環境的狀態,持續了好幾天。
  • 處理過程中,還踩到了兩個技術陷阱:一個來自 Kaniko/Docker 的快取鍵規則,另一個來自 docker compose 的 project 名稱解析規則。
  • 這兩起事故的共通點是:原本應該停下來的地方,系統卻默默往前走了。

事故 1:Firebase 以預設檔案的形式發行到生產環境

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 確認它的內容是否真的不等於範例檔。因為「檔案存在」只代表有個東西放在那裡,並不能排除只是把預設檔複製了一份。

「不存在就用替代品繼續」這個設計本身沒有錯。開發時重視便利性,發行前重視安全性,兩者需求發生的時點本來就不同。把同一種輸入檢查,依不同階段切換成「警告後繼續」與「立即停止」,這是個理所當然、卻很容易被忽略的教訓。

事故 2:CVE 修補沒有送達到生產環境

這一段比較複雜,依時間順序說明。

  1. 先提交修正 Firebase 未初始化根因的版本(版本 A)。當時後端與容器映像檔沒有任何變更(因為原因只在 iOS 端檔案配置),所以判定「這次 API 映像檔不需要重新建置」。
  2. 提交之後,另有一件事在平行進行:修正 trivy image scan 找出的作業系統套件 CVE。這是一個會同時修改 api / web / admin 三個 Dockerfile 的變更,而且是在最初那個判斷之後才追加進來的修改。
  3. 把 CVE 修補合併到 release 分支並反映到生產環境時,把最初那個「不需要重建映像檔」的判斷一路沿用下去,結果忘了更新生產環境的映像檔標籤。
  4. 幾天後,ArgoCD 發出 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 也能照常合併,沒有人會發現。

陷阱 1: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 來做快取失效,這一點一定要確認。

陷阱 2:本地驗證其實什麼都沒驗證到

把 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 部署流程),但形態非常相似。

  • 開發期間正確的 fallback,卻在發行與正式環境反映的最後防線也一樣生效
  • 把一次下過的「沒有影響」判斷,錯誤套用到之後新增的變更上

這兩種情況都不是 CI 或測試能直接抓到的失敗。CI 驗證的是「寫下來的程式是否依照規格運作」,但「該送達正式環境的東西是否真的有送到」是另一個層次的問題。

為了避免再發生,我現在在檢查清單與機制兩方面都加入了以下兩點:

  • 在建立發行物的最後指令之前,對秘密檔案的檢查不只看是否存在,還會進一步確認內容是否與範例檔相同(check-firebase-release)
  • 對於正式環境映像檔標籤是否有漏改,除了仰賴監控(ArgoCD 的 health-degraded 通知)來發現之外,也改成在 release 分支每增加一個 commit 時,都重新檢查一次「這個 commit 是否需要重建正式環境映像檔」

結束語

繼「做出 App」和「讓 App 通過審查」之後,這次我也在「讓 App 持續運作」這件事上學到了很多。對個人開發者來說,這類基礎建設與 CI/CD 的陷阱,常常都是親自踩過一次之後,才真的理解那些規格細節。Kaniko 的快取鍵規則、docker compose 的 project 名稱解析規則,官方文件其實都有寫,但在真正出事之前,我都沒把它們當成跟自己有關的事情。

MineWatch 現在仍然由我個人持續維運中。下次如果再踩到什麼大事故,我也會再寫成文章。


JQIT 的工程師有 95% 以上是未經驗錄用。
如果可以,也歡迎來我們的企業網站逛逛。

:sparkles:可以從零開始學習!一起挑戰吧!:sparkles:

我們也有 note 和 X ↓



原文出處:https://qiita.com/jqit-yukiono/items/21973aaa1c605b34c174


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝62   💬2  
856
🥈
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登