Flutter CI/CD Debugging: Three Build Failures in One Day

前言:當建置一直紅燈 CI/CD pipeline 亮紅燈是開發日常,但連續遇到三個不同層面的問題,從 iOS codesigning、Android Gradle、到 Google Play API,這就值得記錄下來了。 這篇文章記錄我在同一天內遇到的三個建置失敗,以及逐步排除的過程。每個問題都有其獨特的根因,但也反映出 CI/CD 環境的複雜性。 問題一:iOS Keychain 解鎖失敗 症狀 Jenkins 建置在 iOS 階段失敗,錯誤訊息: [!] Error unlocking keychain at path: fastlane_keychain Command failed with exit status 51 macOS Keychain 運作機制 在深入問題之前,先了解 macOS Keychain 的運作方式: 關鍵概念: macOS 可以有多個 Keychain,每個都有獨立密碼 憑證必須在「已解鎖」的 Keychain 中才能被 codesign 使用 CI 環境通常會建立專用的 Keychain,避免影響系統 Keychain Fastlane Match 與 Keychain 的互動流程 調查過程 Exit status 51 代表「密碼錯誤」。SSH 進 Jenkins Mac mini 確認: ...

January 17, 2026 · 2 分鐘 · Peter

Strapi Plugin 覆寫陷阱:為什麼你的 Override 沒有生效?

前言:一個看似簡單的需求 需求很單純:讓 Email 驗證信中的連結顯示前端網址(www.example.com),而不是後台網址(api.example.com)。這樣用戶不會看到內部系統架構。 外包商寫了這段程式碼: // ❌ 看起來合理,但完全沒效果 plugin.services.user.sendConfirmationEmail = async function(user) { // 自訂邏輯... } 部署後,Email 中的連結依然指向後台。為什麼? 問題根源:Factory Function 陷阱 翻開 Strapi 原始碼,發現 plugin.services.user 不是一個物件,而是一個 factory function: // Strapi 內部實作(簡化版) plugin.services.user = (context) => { return { sendConfirmationEmail: async (user) => { /* 原始邏輯 */ }, // 其他方法... } } 這意味著什麼? 每次 Strapi 需要 user service 時,它會呼叫這個 factory function 來取得一個新的 service 實例。你直接覆蓋 sendConfirmationEmail 屬性,等於在一個 function 物件上加屬性——factory 被呼叫時根本不會讀取這個屬性。 不這樣理解會怎樣? 你會像外包商一樣,花兩天 debug 卻找不到原因,因為程式碼完全沒有報錯,只是靜靜地被忽略。 解決方案:Factory Wrapper 模式 正確的做法是包裝原本的 factory function: // ✅ Factory Wrapper 模式 const originalUserServiceFactory = plugin.services.user; plugin.services.user = (context) => { // 先取得原始 service 實例 const originalUserService = originalUserServiceFactory(context); return { ...originalUserService, // 保留所有原始方法 // 覆寫特定方法 async sendConfirmationEmail(user) { // 你的自訂邏輯 const confirmationUrl = `${FRONTEND_URL}/verifyEmail`; // ... }, }; }; 為什麼這樣有效? ...

January 15, 2026 · 2 分鐘 · Peter

AWS 跨區域遷移後的技術債清理:Strapi URL 的隱藏陷阱

接手專案,先看帳單 因為老闆信用卡到期了要換新卡,我順便看了一下 AWS 帳單金額,發現比預期高。之前詢問外包商技術長(已離職),得到的回覆是:「服務都已經從新加坡遷移到台北了,除了 S3 有保留做備份,其他都刪除了。」 身為工程師,最不能接受的就是「應該是這樣」。我決定親自盤點。 名詞解釋 在繼續之前,先解釋一下會提到的 AWS 服務: 服務 說明 費用特性 S3 (Simple Storage Service) 物件儲存服務,用來存放檔案、圖片、影片 按儲存容量和請求次數計費 NAT Gateway 讓私有子網路的資源能存取網際網路 按小時計費,即使沒流量也要錢 Elastic IP 固定的公開 IP 位址 使用中免費,未關聯則收費 VPC (Virtual Private Cloud) 虛擬私有網路,隔離你的雲端資源 VPC 本身免費,但相關資源收費 Network Load Balancer 負載平衡器,分散流量到多台伺服器 按小時和處理的資料量計費 ECR (Elastic Container Registry) Docker 映像檔儲存庫 按儲存容量計費 重點是:有些資源即使沒有流量,只要存在就會收費。NAT Gateway 和未關聯的 Elastic IP 就是典型的「隱形殺手」。 盤點遺留資源 # 檢查 EKS 叢集(Kubernetes 服務) aws eks list-clusters --region ap-southeast-1 # 結果:空的 ✓ # 檢查 RDS(資料庫) aws rds describe-db-instances --region ap-southeast-1 # 結果:空的 ✓ # 檢查 NAT Gateway aws ec2 describe-nat-gateways --region ap-southeast-1 \ --filter "Name=state,Values=available" # 結果:2 個還在跑 完整盤點結果: ...

January 10, 2026 · 3 分鐘 · Peter

Kubernetes Staging 環境省錢術:從踩坑到正確實作

起因:老闆想省錢 「Staging 環境平常沒人用,每個月還要燒 $45-60 美金,能不能想辦法省一點?」 Staging 環境的成本來自兩個地方:RDS 資料庫(約 $15-20/月)和 EKS 節點(約 $30-40/月)。既然平常沒在用,我想到了一個方案:不用的時候關掉,需要的時候再打開。 於是我寫了兩個腳本: staging-start.sh:啟動 RDS、擴充節點、部署應用 staging-stop.sh:刪除部署、縮減節點、停止 RDS # staging-stop.sh 核心邏輯 kubectl delete deployment app-strapi-stg app-web-stg aws eks update-nodegroup-config \ --cluster-name my-cluster \ --nodegroup-name my-nodegroup \ --scaling-config minSize=0,maxSize=2,desiredSize=1 # 從 2 縮到 1 aws rds stop-db-instance --db-instance-identifier my-stg-rds 看起來很合理,但這裡有個問題:Production 和 Staging 共用同一個 nodegroup。 踩坑:AWS 隨機選擇刪除節點 執行 staging-stop.sh 縮減節點時,AWS 會隨機選擇要終止哪個節點。當時的配置: 節點 A:運行 Production pods 節點 B:運行 Staging pods 我期望刪除節點 B,但 AWS 選了節點 A。Production pods 被強制遷移,觸發了重新調度。 ...

January 6, 2026 · 3 分鐘 · Peter

同樣的功能,為什麼 Flutter 比 Vue 難 Debug 十倍?從實戰到架構的六層反思

前言:同一個功能,截然不同的 Debug 體驗 最近在維護一個同時有 Vue 前端和 Flutter App 的專案。兩邊都要實作「關於我們」頁面的選單過濾邏輯——根據不同情境顯示或隱藏特定項目。 Vue 那邊:兩天內改了十幾個 commit,每次都是小幅調整,順順地完成。 Flutter 這邊:卡了一整天,改了一個地方沒效果,懷疑方向錯誤,來回折騰。 同樣的業務邏輯,為什麼 debug 體驗差這麼多? 這篇文章從 debug 實戰出發,一路延伸到架構層面的反思。我們會探討 Domain Model 的防禦能力、Clean Architecture 的責任邊界、扁平架構的取捨、BFF 的可靠性價值,最後揭露這次 debug 困難的真正原因——交接代碼的信任陷阱。 Part 1:Debug 實戰 Vue:問題在 UI 顯示層 Vue 那邊的典型修正長這樣: // Vue - 在 computed 裡加一個 filter const filteredPageTabs = computed(() => { return response.value.pageTabs .filter(item => item.subtitle !== 'Service') .map(item => { if (item.subtitle === 'ABOUT_US') { return { ...item, subTabs: item.subTabs?.filter( subTab => subTab.title !== '醫療團隊' ) || [] } } return item }) }) 問題本質:資料從 API 回來是正確且完整的,只需要決定「哪些要顯示在畫面上」。 Debug 過程:打開 Vue DevTools → 看 store 資料 → 加個 filter → 完成。整個過程不超過 10 分鐘。 ...

January 4, 2026 · 5 分鐘 · Peter

刪了 52 萬筆資料,為什麼硬碟空間沒變小?

「奇怪,我明明刪了 52 萬筆資料,為什麼資料表還是 207MB?」 這是我今天在清理資料庫時遇到的真實情況。如果你也曾經困惑過這個問題,這篇文章會告訴你背後的原因。 事情是這樣的 專案的 user_notifications 資料表累積了幾十萬筆推播通知記錄,佔用了 207MB 空間。為了控制資料庫大小,我寫了一個 cron job 來清理超過 7 天的舊資料: // 刪除 7 天前的通知 const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); await strapi.db.query('api::user-notification.user-notification').deleteMany({ where: { createdAt: { $lt: sevenDaysAgo.toISOString() } }, }); 執行結果很漂亮: [Cleanup] Successfully deleted 521604 old user notifications 刪除了 521,604 筆!只剩下約 2 萬筆近期資料。 但當我打開 DBeaver 檢查時… 207MB?資料都刪了,空間怎麼沒變? 為什麼會這樣?理解 PostgreSQL 的 MVCC 這不是 bug,而是 PostgreSQL 的設計特性。 DELETE 不是真的刪除 PostgreSQL 使用 MVCC(Multi-Version Concurrency Control) 來處理並發交易。當你執行 DELETE 時,PostgreSQL 不會真的把資料從磁碟上移除,而是: 將該行標記為「已刪除」(稱為 dead tuple) 保留原始資料,直到沒有任何交易需要參照它 新的查詢看不到這些行,但它們仍佔用磁碟空間 為什麼要這樣設計? 效能考量:標記刪除比實際移除資料快非常多 並發安全:其他正在執行的 transaction 可能還需要看到舊版本 ACID 保證:確保 transaction isolation 不處理會怎樣? ...

December 31, 2025 · 3 分鐘 · Peter

Flutter App 首頁 Banner 消失之謎:一場無效 API Token 的偵探之旅

前言:當 Banner 在眾目睽睽下消失 如果你曾經盯著一個「昨天還好好的」功能,然後花了幾小時才發現問題根本不在你想的地方——恭喜你,你已經正式成為資深工程師了。 這次的主角是一個 Flutter App 的首頁輪播 Banner。用戶回報說「Banner 不見了」,而我的第一反應是:「一定是最近的 commit 搞壞的!」 (劇透:不是。) 第一階段:追蹤嫌疑犯 嫌疑人一號:最近的 Git Commit 最近剛好有一個 commit 修改了後端的 middleware,用來處理認證相關的端點。自然而然,我先從這裡開始查: git show abc123 # fix: add middleware to strip auth header for public endpoints 看起來這個 middleware 只處理 /api/auth/forgot-password 這類認證端點,跟 Banner API 完全無關。 結論:無罪釋放。 嫌疑人二號:Strapi 權限設定 接下來檢查 Strapi Admin 的權限設定。Public 角色的 app-home-page 權限: 角色 find 權限 Authenticated ✅ 已開啟 Public ✅ 已開啟 權限設定完全正確。 結論:也不是兇手。 第二階段:真相大白 測試 REST API 直接用 curl 測試 REST API: ...

December 30, 2025 · 2 分鐘 · Peter

為什麼技術選型 CMS 我要選 Strapi?2024 年中的預算與系統分析決策

引言:一個技術選型的起點 2024 年 6 月,我坐在會議室裡,面對著老闆和行銷總監,準備報告我對公司新系統 CMS 的技術選型建議。這不是一個輕鬆的決定——選錯了,可能浪費數百萬的開發成本;選對了,能為公司省下可觀的人力支出。 經過數週的研究與分析,作為一個架構規劃師同時也是技術決策者,我最終選擇了 Strapi 作為我們的 Headless CMS 解決方案。這篇文章將分享我的決策過程、考量因素,以及最重要的——這個選擇如何為公司省下大筆預算。 什麼是 Headless CMS? 在深入 Strapi 之前,先理解 Headless CMS 的核心概念。 傳統 CMS vs Headless CMS 傳統 CMS 將前端與後端緊密耦合,網站的外觀和內容管理綁在一起。而 Headless CMS 則專注於內容管理和 API 提供,讓前端團隊可以使用任何技術框架來消費這些 API。 Headless CMS 的核心優勢 特性 傳統 CMS Headless CMS 前後端耦合 緊密耦合 完全分離 前端技術選擇 受限於 CMS 模板 任意框架(Vue、React、Flutter) 多平台支援 僅限網頁 Web、App、IoT 皆可 擴展性 中等 極高 API 優先 否 是 為什麼選擇 Strapi?三大核心原因 原因一:前後端完全分離 Strapi 作為 Headless CMS,天生就是為了前後端分離而設計。這意味著: ...

December 23, 2025 · 3 分鐘 · Peter

15 次 Build Failed:一場 Jenkins + Flutter CI/CD 的史詩級除錯之旅

前言:當 Build Failed 成為日常 在過去的 19 個小時裡,我經歷了 15 次 build failed,產生了 15 個 fix commits。如果你覺得這很誇張,讓我告訴你更誇張的:最後一個 bug 是 git describe 在多個 tag 指向同一 commit 時會隨機返回其中一個。 是的,隨機。在 CI/CD Pipeline 裡。 這篇文章完整記錄這場除錯馬拉松,從最初的 Fastlane 版本問題,到 Discord 通知功能的實作與修復,再到 Ruby 相容性地獄,最後揭開 git 鮮為人知的行為。泡杯咖啡,這會是一段旅程。 第一章:Fastlane 與 Bundler 的糾葛 問題 1:Fastlane 版本不一致 Commit: fix(jenkins): use bundle exec for fastlane to ensure version consistency Jenkins 機器上有全域安裝的 Fastlane,但版本與 Gemfile.lock 指定的不同。這導致某些 action 行為不一致。 // Before: 使用全域 fastlane sh 'fastlane ios build' // After: 透過 Bundler 執行,確保版本一致 sh 'bundle exec fastlane ios build' 學習:在 CI 環境中,永遠使用 bundle exec 執行 Ruby 工具,確保版本與 lockfile 一致。 ...

December 21, 2025 · 5 分鐘 · Peter

Claude Code Token 不夠用?從 $20 升到 $100 還是燒光:我學到的教訓

前言:一個月燒掉 $100 的真實故事 如果你正在考慮升級 Claude Code 的訂閱方案,或者已經升級了卻發現 token 還是不夠用,那你來對地方了。 這不是一篇推銷文,而是一個真實的使用心得。 我從 Pro Plan($20/月) 開始使用 Claude Code,很快就發現 token 不夠用。心想:「升級到 Max Plan($100/月)應該就沒問題了吧?」 結果呢? Max Plan 的 token 也不夠用。 更尷尬的是,我花了大把的 token 去處理一些看似簡單的 bug,像是: 體脂率的小數點精度問題 排便次數顯示 null 和 0 的差異 這些問題聽起來簡單,實際上卻各花了大量 token 去「探索」程式碼。 直到我發現問題的根源不是 token 不夠 (Pro plan是真的不夠!),而是我沒有找到對的 Skill 來處理這類問題。 問題分析:Token 都燒去哪了? 讓我用一張圖來說明沒有使用正確 Skill 時的除錯流程: 看到問題了嗎? 這就是「漫無目的的探索」——AI 不知道該往哪裡找,所以它嘗試讀取所有可能相關的檔案,每次讀取都在消耗 token。 Token 消耗的真相 操作類型 Token 消耗 實際價值 讀取不相關的檔案 高 零 廣泛搜尋 grep/glob 中 低(通常需要多次) 隨機嘗試修復 高 可能為負(引入新 bug) 來回確認「這樣對嗎?」 中 低 我的真實案例:為了修一個體脂率顯示的小數點問題,AI 讀取了: ...

December 20, 2025 · 3 分鐘 · Peter