Docker BuildKit Cache Mount 的隱形陷阱

症狀:部署成功,程式碼卻是舊的 一個新功能已經 commit 並推上 Git,Jenkins pipeline 顯示建置成功、部署完成,Kubernetes Pod 也順利啟動。進入 Pod 檢查——程式碼還是舊的。 這不是網路延遲、不是 image pull policy、不是 tag 衝突。問題藏在 Dockerfile 裡一行看起來「很聰明」的快取優化。 背景知識:Docker 建置的關鍵概念 在進入除錯過程之前,先釐清幾個 Docker 建置中的核心概念: Docker Image 與 Layer(映像檔與分層) Docker 映像檔由多個唯讀的「層」(layer)堆疊而成。Dockerfile 裡的每一條指令(如 COPY、RUN)都會產生一層。Docker 會對每一層計算 hash,下次建置時如果指令和輸入都沒變,就直接重用該層——這就是 layer cache。 BuildKit BuildKit 是 Docker 18.09 之後引入的新一代建置引擎(透過 DOCKER_BUILDKIT=1 啟用)。相比傳統引擎,BuildKit 支援平行建置、更聰明的快取策略,以及本文主角——--mount=type=cache(cache mount)語法。 Cache Mount(--mount=type=cache) BuildKit 專有的功能。它在 RUN 指令執行期間,將一個持久化的目錄掛載到容器內的指定路徑。這個目錄由 BuildKit 管理,不會被寫入最終的 image layer,但內容會跨越不同次的 docker build 保留下來。常見用途是快取套件管理器的下載目錄(如 yarn cache),避免每次建置都重新下載。 Inline Cache 與 --cache-from 另一種 BuildKit 快取策略。透過 --build-arg BUILDKIT_INLINE_CACHE=1 把快取 metadata 嵌入產出的 image,再用 --cache-from 從遠端 registry 拉取。這讓不同機器(例如 CI runner)也能共享建置快取。 ...

February 18, 2026 · 2 分鐘 · Peter

Flutter iOS 冷啟動閃退:Debug 模式的隱藏陷阱

問題現象:Xcode 正常,冷啟動閃退 最近在開發 Flutter iOS App 時遇到一個詭異的問題:透過 Xcode 按下 Run 按鈕啟動 App 完全正常,但當我停止 Debugger、從多工畫面滑掉 App、再從主畫面點擊圖示重新開啟時,App 只顯示 Launch Screen 就立刻閃退。 這個問題讓我走了不少彎路,最後發現答案簡單得令人意外。 誤導方向:UIScene 與 Plugin 註冊 由於 Xcode 26 開始顯示 UIScene lifecycle will soon be required 警告,我最初懷疑是 UIScene 遷移不完整導致的問題。接著又懷疑是 Plugin 註冊的 race condition,甚至在 GeneratedPluginRegistrant.m 中加入大量 debug log 來追蹤每個 Plugin 的註冊狀態。 嘗試過的「修復」包括: 完整實作 SceneDelegate 與 FlutterSceneDelegate 在 Flutter Engine 初始化前加入 0.5 秒延遲 逐一排除可能有問題的 Plugin 這些方向全部是錯的。 找到根本原因:iOS Console Log 關鍵轉折點是查看 iOS 的 Console log。使用 Xcode 的 Devices and Simulators > Open Console 或 macOS 的 Console.app 連接 iPhone,重現冷啟動閃退後,看到了這段關鍵訊息: ...

February 17, 2026 · 2 分鐘 · Peter

功能部署後憑空消失?一場 Jenkins Workspace 的除錯之旅

問題:功能「先在後不在」 我們替 Strapi 後台新增了 MFA(Multi-Factor Authentication)雙因素認證功能。第一次部署到 STG 時一切正常——登入攔截、TOTP 驗證、QR Code 設定流程都運作良好。 但幾天後,因為其他功能的修改推了新的 git tag stg-1.24,部署流程順利完成,Jenkins 顯示綠燈——打開 STG 後台,MFA 的登入流程卻完全不見了。 沒有錯誤訊息、沒有 crash log,功能就這樣無聲無息地消失。 這比「功能從未出現」更令人困惑:明明之前部署是好的,程式碼也沒有人動過 MFA 相關的部分,為什麼會突然不見? 調查過程:逐層排除 確認 Git Tag 內容 第一個直覺是——程式碼真的有被包進 tag 嗎? # 確認 tag 指向的 commit 包含 MFA 程式碼 git show stg-1.24:src/index.ts | grep -i mfa 結果確認 src/index.ts 中有 import { registerMfaRoutes } from './mfa/controller',src/mfa/ 目錄也完整存在。Git tag 本身沒問題。 檢查 Pod 內的實際檔案 既然 tag 正確,問題可能出在建置或部署階段。直接進到 Kubernetes Pod 裡看: # 進入 STG pod 檢查 kubectl exec -it deployment/strapi-stg -- sh # 檢查編譯後的檔案 ls dist/src/mfa/ # 結果:ls: dist/src/mfa/: No such file or directory # 檢查原始碼 ls src/mfa/ # 結果:ls: src/mfa/: No such file or directory MFA 相關檔案完全不存在於 Pod 中。 甚至連原始碼都沒有被複製進 Docker image。 ...

February 12, 2026 · 3 分鐘 · Peter

升級 Strapi v5.31+ 後 JWT 過期就被登出?問題藏在你沒注意到的內建路由裡

症狀:App 開啟 15 分鐘後被強制登出 使用者回報一個詭異的問題:App 正常使用一段時間後,突然被強制登出。重新登入後又好了,但過一陣子又被踢出去。 時間規律很明確——正好 15 分鐘。這恰好是我們 JWT 的過期時間。 直覺反應是去查前端的 token refresh 邏輯:interceptor 有沒有正確攔截 401?refresh token 有沒有正確儲存?重試機制有沒有 bug? 查了一輪,全部正常。 錯誤的除錯方向 這個問題前前後後花了不少時間,方向一直在前端打轉: 排查方向 結果 Flutter interceptor 邏輯 正常,有攔截 401 並觸發 refresh Token 儲存機制(SecureStorage) 正常,refresh token 有正確保存 JWT 過期時間設定 確認是 15 分鐘,符合預期 網路連線問題 排除,其他 API 都正常 每個環節看起來都沒問題,但結果就是 refresh 失敗。問題出在哪? 轉折點:看 HTTP Status Code 直到有人(或者說,終於有人)去看了 /auth/refresh 實際回傳的 HTTP status code: # JWT 過期後呼叫 refresh curl -X POST https://api.example.com/api/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refreshToken": "valid-refresh-token-here"}' # 預期:200 OK(新的 JWT) # 實際:403 Forbidden 403,不是 401。 ...

February 6, 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

同樣的功能,為什麼 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

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

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

一次錯誤部署引發的 PostgreSQL Sequence 災難:為什麼使用者突然無法解鎖動畫?

「老闆,用戶的解鎖記錄全不見了!」「快把舊資料拉出來灌回去!」在緊急狀況下,我沒想太多就照做了。然後,我不小心埋下了一顆定時炸彈… 🔥 第一幕:災難降臨 2025 年 11 月某日,上午 10:30 Slack 突然炸開: 💬 同事:「完蛋了…我剛剛不小心部署到舊的 commit…」 💬 QA:「欸!為什麼使用者的動畫解鎖記錄都不見了?」 💬 使用者:「我昨天才花金幣解鎖的動畫怎麼不見了?」 💬 老闆:「@所有人 立刻確認影響範圍!」 我打開資料庫一看: SELECT COUNT(*) FROM user_unlocked_animations; -- 結果: 0 😱 所有用戶的解鎖記錄全部消失! 原因:同事不小心部署了一個舊的 Strapi commit,那個版本的 database migration 把 user_unlocked_animations 相關的表全部清空了。 ⚡ 第二幕:老闆的緊急命令 💬 老闆:「快!把之前的用戶解鎖記錄拉出來,灌回現在的資料庫!」 我心裡想:「舊資料插回去,新資料又同時在進來…會不會有問題?」 但老闆在等,使用者在抱怨,沒時間多想,先恢復資料再說。 緊急恢復資料 // 從備份拉出資料,直接插入(包含原始的 ID) blablablabla } // ⚠️ 直接指定了 id,但沒想到要更新 sequence... 執行完畢: ✅ 資料恢復完成! QA 測試:「使用者的解鎖記錄都回來了!」 眾人鬆了一口氣。 💣 第三幕:24 小時後,炸彈引爆 隔天下午 💬 客服:「有使用者回報說無法解鎖動畫!!!」 💬 使用者:「我有 3 個金幣,想解鎖動畫,但一直顯示錯誤!金幣被扣了但動畫沒解鎖!」 ...

November 9, 2025 · 5 分鐘 · Peter