AWS S3 Upload Failed: The Bucket Does Not Allow ACLs
問題發生 在將 Production 環境複製到 Staging 環境後,發現 Strapi CMS 無法上傳圖片到媒體庫,畫面只顯示 Internal Server Error。 Strapi 是一個開源的 Headless CMS(無頭內容管理系統),可以讓開發者快速建立 API,並提供管理後台來管理內容。在這個專案中,我們使用 Strapi 搭配 AWS S3 來儲存上傳的圖片和檔案。 追查過程 第一步:查看 Kubernetes Logs 由於 Strapi 部署在 EKS(Elastic Kubernetes Service,AWS 的託管 Kubernetes 服務)上,我透過 kubectl 指令查看 Pod 的日誌: kubectl logs -f deployment/strapi-stg --tail=100 kubectl 是 Kubernetes 的命令列工具,用來與 Kubernetes 叢集互動。logs 指令可以查看容器的輸出日誌。 第二步:找到錯誤訊息 在日誌中發現關鍵錯誤: error: The bucket does not allow ACLs AccessControlListNotSupported: The bucket does not allow ACLs 這個錯誤訊息指出 S3 Bucket(AWS 的物件儲存服務中的儲存桶)不允許使用 ACL。 ...
當授權資料不可信時,我選擇讓系統安靜地退後一步
延續上一篇的問題 在上一篇文章中,我們遇到了一個問題:Link Table 資料遺失導致使用者沒有角色,所有 API 都回傳 401。 文章最後我拋出了一個問題: 如果你的系統把角色資訊 cache 在 Redis、JWT claim、或 BFF 層,當 Link Table 資料不正確時,系統應該: 名詞解釋: Redis:一種高速的記憶體資料庫(In-Memory Database),常用於快取(Cache)熱門資料,避免每次都查詢主資料庫 JWT claim:JWT Token 內的資料欄位。例如把使用者角色直接寫在 Token 裡:{ "sub": "user_123", "role": "admin" },這樣就不用每次都查 DB BFF(Backend For Frontend):一種架構模式,在前端和後端 API 之間多一層「專為前端服務的後端」,常會在這層做權限快取 立即全站拒絕? 繼續相信 cache? 還是進入 degraded mode? 我的選擇是:進入 Degraded Mode。 這篇文章會解釋為什麼,以及如何實作。 一句話定義 Degraded Mode = 系統已知自己「部分不可信」,主動降級功能以維持安全與可用性。 不是壞了、不是裝沒事,而是: 我知道哪裡壞 我知道哪些功能不能給 我知道要保住什麼 為什麼不選另外兩個方案? ❌ 方案 A:立即全站拒絕 所有 API → 401/503 → 業務全掛 問題: 使用者完全無法使用系統 對業務衝擊太大 「寧可錯殺一百」的策略在商業系統中代價過高 適用場景: 金融交易、醫療處方等「錯了比沒有更糟」的場景 ...
資料庫同步的隱藏陷阱:Link Table 的重要性
問題現象:登入成功卻被拒於門外 最近在 Staging 環境遇到一個詭異的問題:使用者登入成功,拿到了有效的 JWT Token,但存取任何需要認證的 API 都回傳 401 Unauthorized。 # 登入成功,拿到 token POST /api/auth/local → 200 OK { "jwt": "eyJhbGc...xxxxx...your-jwt-token", "user": { "id": 1001, "email": "user@example.com" } } # 但存取個人資料失敗 GET /api/users/me → 401 Unauthorized Token 驗證通過、使用者存在、帳號未被封鎖。問題到底在哪? 根本原因:遺失的 Link Table 經過一番追查,發現問題出在資料庫同步時漏掉了關聯表(Link Table)。 什麼是 Link Table? 在關聯式資料庫中,多對多關係需要透過中間表來建立。這個中間表就是 Link Table(也稱為 Junction Table、Join Table、或 Pivot Table)。 使用者與角色的關係: 一個使用者可以有多個角色(User → Roles) 一個角色可以分配給多個使用者(Role → Users) 這是典型的多對多關係 各種 ORM 的 Link Table 命名 不同框架的 Link Table 命名慣例不同,但概念完全相同: ORM/Framework Link Table 範例 備註 Django user_groups, user_permissions 使用 _ 連接 Laravel role_user, permission_role 字母順序排列 TypeORM user_roles_role 較長的命名 Prisma _UserToRole 以 _ 開頭 Sequelize UserRoles 駝峰命名 問題的本質:資料不完整 當我們同步資料庫時,通常會注意主要的資料表: ...
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 確認: ...
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`; // ... }, }; }; 為什麼這樣有效? ...
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 個還在跑 完整盤點結果: ...
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 被強制遷移,觸發了重新調度。 ...
同樣的功能,為什麼 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 分鐘。 ...
刪了 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 不處理會怎樣? ...
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: ...