Files
CFDivePlatform/openspec/changes/bake-image-remove-bind-mount/design.md
T
a620906209 6a7d98b0fe
Run Tests / test (pull_request) Successful in 32s
feat(docker): 移除 bind mount,程式碼與 vendor 烘進 image
Dockerfile 改 multi-stage(app + web),app/scheduler/queue-worker/
reverb/nginx 五個服務都不再依賴 ./:/var/www bind mount 取得程式碼;
nginx 改本地 build 從 app 階段 COPY --from 取得 public/,public/storage
symlink 在兩階段各自於 build 時建立。docker-compose.yml 新增具名 volume
storage-app-public 讓上傳檔案跨 image 重建保留;env_file: 取代原本
bind mount 帶入 .env 的方式,只給四個 Laravel runtime 服務。
compose.override.yml 補上開發用 bind mount 維持改 code 立即生效;
docker-entrypoint.sh 收斂到只剩目錄權限設定,migration/cache/swagger
移到 deploy.yml 統一負責,避免職責重疊。

實作驗證時發現並修復三個規劃階段沒預見的問題:app 服務的 build: 缺
target: app 導致預設建到最後一個 stage(image 實際變成 nginx);
.dockerignore 需排除本機既有的 public/storage 符號連結(Windows 上
會讓 build context 打包失敗);env_file: 讓 .env 全部變數變成真實
環境變數、蓋掉 phpunit.xml 的測試隔離設定,已擴大 tests/bootstrap.php
的強制覆蓋清單修復(239 tests 全綠)。

本機 dev 模式與 production 模式(無 override)皆完整驗證:healthcheck、
migration/cache/swagger、上傳檔案持久性、nginx 獨立於 app 服務靜態檔
與 storage 檔案、.env 熱更新、nginx 無 .env 存取權。

openspec change: bake-image-remove-bind-mount

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142LC1kQb8EyV59TjHDkhWS
2026-08-04 16:59:18 +08:00

145 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
`docker-compose.yml` 目前對 `app``nginx``scheduler``queue-worker``reverb` 五個服務都掛 `./:/var/www` bind mount`Dockerfile` 完全沒有 `COPY . /var/www`。這代表:
- build 出來的 `cfdive-platform` image 是空殼,不含 `app/``routes/``vendor/`,無法脫離主機獨立運作。
- 部署(`.gitea/workflows/deploy.yml`)流程是 `git reset --hard` 改寫主機檔案 → `docker compose exec app composer install`(裝在 bind mount 出去的目錄上)→ artisan cache 系列,全程沒有 `docker compose build`
- `docker/php/docker-entrypoint.sh` 承擔了大量「假設容器內是主機目錄」的邏輯:`.env` 不存在就從 `.env.example` 複製並 `key:generate`、composer.lock 內容比對決定是否重裝 vendor。
- 本機開發長期依賴 bind mount 做到「改 code 立即生效」(後端用 `kill -USR2 1` 讓 OPcache 重新驗證,前端 Vite HMR)。
P1log 改 stderr、session 改 Redis)與 `file-storage-s3`(上傳檔案可切 S3,但 VPS 尚未真的切換,`.env` 仍是 `FILESYSTEM_DISK=local`)已經拆掉了「container 必須綁死主機磁碟」的兩個主要理由(log 與可選的檔案儲存),這次要處理最後一塊:程式碼本身。
## Goals / Non-Goals
**Goals:**
- productionVPS 實際部署用)的 `docker-compose.yml` 不再依賴主機 bind mount 取得程式碼,image build 完成即可獨立運作。
- 部署流程改為每次 `git pull` 後重新 `docker compose build`,取代現在的「改主機檔案 + exec 裝套件」。
- `.env` 改用 `env_file:` 傳遞,不再依賴 bind mount 把整個目錄(含 `.env`)帶進容器。
- 本機開發體驗(改 code 立即生效)不能被破壞——透過既有的 `compose.override.yml` 機制(`compose-cloud-baseline` 已建立此慣例)讓開發環境疊加 bind mountproduction compose 本身不含。
**Non-Goals:**
- 不強制本次一併把 VPS 正式切到 `FILESYSTEM_DISK=s3``file-storage-s3` 程式邏輯已完成,但帳號申請與正式切換是獨立決策,留給後續變更)。
- 不處理零停機(blue-green / rolling)部署——`docker compose build && up -d` 期間服務仍會有短暫中斷,此為已知取捨,不在本次解決範圍。
- 不改動 `frontend/` 的既有 build 流程(`frontend` 服務本來就是獨立 build、不掛 bind mount,不受影響)。
## Decisions
### 1. Dockerfile 統一策略:先 copy composer manifests、install vendor,再 copy 全專案
`docker-php-ext-install` 之後、`ENTRYPOINT` 之前,依序:(1) `COPY composer.json composer.lock /var/www/`(2) `RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts`(3) `COPY . /var/www`(4) 重新執行 `chown -R www-data:www-data``storage`/`bootstrap/cache` 權限設定(COPY 進來的檔案預設是 root)。這個順序讓 Docker layer cache 生效——只要 `composer.json`/`composer.lock` 沒變動,重建 image 時 `composer install` 這層可以直接命中快取、不重跑,只有應用程式碼變動的部分需要重新 COPY。**這是本次唯一策略,不與其他順序並存**(先前草稿一度在 Risk 段落與此處出現順序不一致,已統一)。
**替代方案考慮**:用 multi-stage buildcomposer stage 只複製 vendor 進最終 image)——本次不採用,因為專案規模小、build 時間不是痛點,multi-stage 增加的複雜度不值得;先用單階段搭配上述 layer cache 順序,未來若 image 太大或 build 太慢再考慮 multi-stage。
**`.dockerignore` 是本次的必要項目,不是可有可無的優化**:`COPY . /var/www` 會把 build context 內所有內容複製進 image,若無 `.dockerignore` 排除,`vendor/`(本機可能已存在、與容器內版本不一致)、`node_modules/``.env`(含資料庫密碼等機敏資訊)、`storage/app/public/*`(本機開發時已上傳的檔案,不應烘進 image)都會被複製進去,造成 image 內容錯誤或機敏資訊外洩進 image layer(即使之後刪除檔案,內容仍留在較早的 layer 歷史中)。新增 `.dockerignore` 排除上述路徑,屬於本次變更的正確性要求。
**composer scripts 驗證結果,`COPY . /var/www` 之後需補執行的步驟**:查證 `composer.json``scripts` 區塊,確認:
- `post-autoload-dump` 定義了 `Illuminate\Foundation\ComposerScripts::postAutoloadDump``@php artisan package:discover --ansi`——這兩者依賴 Laravel 應用程式碼(`artisan``bootstrap/`)已存在才能執行,但 Decision 1 的 `composer install` 發生在 `COPY . /var/www` **之前**(只有 composer manifests),此時執行會失敗,因此必須用 `--no-scripts` 跳過。
- `post-update-cmd``post-root-package-install``post-create-project-cmd``composer update``create-project` 才會觸發的 hook,一般 `composer install` 不會執行,不受影響,不需處理。
- 因此 Dockerfile 在 `COPY . /var/www` **之後**必須手動補執行 `composer dump-autoload --optimize`(重新掃描此時才存在的 `app/` 目錄,產生完整的 optimized classmap`--no-scripts` 之前產生的 classmap 缺少專案自身 `App\` 命名空間下的類別)與 `php artisan package:discover --ansi`(等效於原本 `post-autoload-dump` 會執行的服務提供者自動發現,寫入 `bootstrap/cache/packages.php``services.php`),否則跳過的 `--no-scripts` 造成的缺口不會被補上。**修正**:這裡不再重複加 `--no-dev`——`--no-dev` 是控制 `composer install` 階段要不要把 dev 依賴裝進 `vendor/` 的旗標,先前的 `composer install --no-dev` 已經決定了 vendor 內容不含 dev 套件;後續的 `dump-autoload` 只是重新掃描「已經裝好的 vendor 內容」+這次新出現的 `app/` 目錄來產生 classmap,重複加 `--no-dev` 是多餘的,拿掉能讓兩個指令的職責更清楚(install 決定裝什麼、dump-autoload 只重新產生對應的 classmap),也符合 Laravel/Docker 社群常見的分層 build 慣例。
### 2. Production compose 移除 bind mount,開發環境靠既有版控的 `compose.override.yml` 疊加
`docker-compose.yml`production 用)移除 app/nginx/scheduler/queue-worker/reverb 的 `./:/var/www`。**修正**`compose.override.yml` 實際上**已經進版控**`git ls-files` 確認追蹤中,內含 `phpmyadmin`/`mailpit``compose-cloud-baseline` spec 已規範其用途)——先前草稿誤判為「被 `.gitignore` 排除、不進版控」(`.gitignore` 排除的是另一個沒人使用的檔名 `docker-compose.override.yml`),已更正。既然是既有已版控檔案,直接在其中新增這五個服務的 `./:/var/www` bind mount 即可,不需要額外的 `.example` 範本檔案——這個檔案本身就是所有開發者共用的標準範本。開發者本機執行 `docker compose up`(不指定 `-f`)時自動疊加、維持「改 code 立即生效」的體驗;VPS 執行 `docker compose -f docker-compose.yml up -d`(已是現行慣例,`compose-cloud-baseline` 已要求正式環境不吃 override)不受影響。
**替代方案考慮**:改用 volume mount 只掛特定子目錄(如只掛 `app/``resources/`)——本次不採用,因為切分維護成本高且容易漏掉新增目錄,不如整個目錄一起在 override 掛/不掛。
### 3. 上傳檔案改用具名 volume `storage-app-public` 過渡,不強制切 S3
新增具名 volume,命名為 `storage-app-public`(定名,不再是待決項目),掛載至 `app``nginx` 服務容器內的 **`/var/www/storage/app/public`**(絕對路徑,明確指定,避免與相對路徑寫法混淆)這一層路徑,讓使用者上傳的檔案在 image 重建後仍保留。
**為什麼不強制先切 S3**:R2 帳號申請與正式切換是獨立於本次「拿掉 bind mount」的決策(`file-storage-s3` 已記錄暫緩),不應該把兩個決策綁在一起、互相卡進度。具名 volume 是本次的暫時解法,日後若正式切到 S3,這個 volume 可以直接移除(`storage/app/public` 不再需要持久化,因為檔案都在 S3 上)。
**替代方案考慮**:把 `storage/app/public` 也一起 COPY 進 image——不採用,因為使用者上傳的檔案是執行期產生的資料,不該烘進 image(每次 rebuild 會用 build context 裡的舊內容覆蓋掉 volume 資料,或造成 image 越滾越大)。
### 4. nginx 的程式碼來源:改用 Dockerfile multi-stage 的獨立 build target,不依賴任何執行期共享機制
**這是先前草稿的一個實質缺漏**:拿掉 `./:/var/www` bind mount 後,`nginx` 服務(原本用官方 `nginx:alpine` image + bind mount 取得 `/var/www/public`)完全沒有其他方式取得 Laravel 的 `public/` 目錄——先前草稿只處理了 app/scheduler/queue-worker/reverb 四個跑 `cfdive-platform` image 的服務,遺漏了 nginx 這個用不同 base image、且職責只是靜態檔案服務+反向代理的服務。
**決策**:把 `Dockerfile` 改為 multi-stage
1. 第一階段 `FROM php:8.2-fpm AS app`(即 Decision 1 描述的既有內容:composer install → COPY . /var/www → 建立 `public/storage` 符號連結,見下方符號連結策略)。
2. 新增第二階段 `FROM nginx:alpine AS web`,執行 `COPY --from=app /var/www/public /var/www/public``app` 階段 build 完成的 `public/` 目錄複製進 nginx 專用的 image,再 `COPY docker/nginx/conf.d/ /etc/nginx/conf.d/` 帶入既有 nginx 設定。
3. `docker-compose.yml``nginx` 服務改為 `build: {context: ., dockerfile: Dockerfile, target: web}`(而非 `image: nginx:alpine` 直接從 registry 拉取),`image:` 欄位命名為 `cfdive-nginx-web`(與既有 `cfdive-frontend`——Vue/Vite 前端——區分,避免混淆)。
**為什麼不用共享 volume 讓 nginx 讀 app 的 `public/`**:具名 volume 一旦第一次由某容器掛載並帶入初始內容,之後即使 image 重新 build(新程式碼、新前端資源),volume 內容不會自動更新(Docker 只在 volume 為空時才從 image 複製一次)——這會導致「重新部署後 nginx 仍在服務舊的靜態資源」的嚴重 bug。改用 `COPY --from=app` 的 multi-stage 方式,`web` 階段的 `public/` 內容是每次 `docker compose build` 都重新從 `app` 階段複製的,沒有這個過期風險。
**與 Decision 1「不採用 multi-stage」的關係**Decision 1 拒絕的是「composer stage 只複製 vendor 進最終 image」這種為了縮小 app image 體積的 multi-stage 優化,理由是複雜度不值得。這裡的 multi-stage 是為了解決「nginx 需要 app 的 public/ 內容」這個功能性缺口,屬於不同目的,兩者不衝突——app 階段本身仍是單一 `RUN composer install` 的簡單流程,沒有引入 vendor 相關的 multi-stage 複雜度。
### 5. `public/storage` symlink 策略:app 與 web 兩階段各自獨立建立,不依賴跨階段繼承
**這是先前草稿的另一個缺漏**:原草稿假設「entrypoint 在 app container 內執行 `storage:link` 之後,nginx 就看得到」——這個假設是錯的,`app``nginx` 是兩個完全獨立的容器檔案系統,一個容器內建立的符號連結不會出現在另一個容器裡。
**決策(修正版)**`config/filesystems.php``links` 設定只有單一固定項目(`public_path('storage') => storage_path('app/public')`),對應的符號連結是一個不依賴執行期狀態、內容固定的相對路徑連結(`public/storage -> ../storage/app/public`)。改用純檔案系統操作、在 **build 階段**建立,且 `app` 與 `web` 兩個階段**各自獨立執行一次**,不依賴 `COPY --from=app``app` 階段建立的符號連結隱性帶到 `web` 階段:
- `app` 階段於 `COPY . /var/www` 之後執行 `RUN ln -sfn ../storage/app/public /var/www/public/storage`
- `web` 階段於 `COPY --from=app /var/www/public /var/www/public` 之後**再執行一次同樣的指令** `RUN ln -sfn ../storage/app/public /var/www/public/storage`
**為什麼不依賴 `COPY --from` 隱性繼承**:雖然 Docker `COPY --from` 技術上會保留符號連結本身(不解析成實體檔案),讓 `web` 階段「順帶」拿到 `app` 階段建立的符號連結,但這是一個隱性、容易被日後維護者忽略的耦合——如果之後有人調整 `COPY --from` 的複製範圍(例如改成只複製特定子目錄、或換成其他複製方式),這個符號連結就會無聲消失且不易察覺。改成兩階段**各自明確宣告**同一件事,即使其中一階段的建置方式改變,另一階段仍不受影響,且從 Dockerfile 原始碼就能直接看出「兩個 image 都需要這個符號連結」的意圖,不需要理解 `COPY --from` 的符號連結保留語意才能推斷。
**`ln -sf` 改為 `ln -sfn`**`-n``--no-dereference`)避免一個常見陷阱——若目的地路徑已經是一個指向目錄的符號連結,`ln -sf`(不含 `-n`)會把新連結建立在該目錄「裡面」而非取代它。對 Docker 重建 image 的情境(layer 快取失效後重跑同一條 `RUN` 指令)加上 `-n`可確保每次執行都是冪等地取代既有符號連結,而不是意外在裡面巢狀建立。
**不使用 `php artisan storage:link`**:因為這只是建立一個符號連結檔案,不需要 Laravel 執行期或資料庫連線。
符號連結指向的實際檔案內容(`storage/app/public/` 下的檔案本身)則透過 Decision 3 的 `storage-app-public` 具名 volume,同時掛載到 `app``nginx` 容器的 `/var/www/storage/app/public` 路徑——兩個容器的符號連結都指向同一個相對路徑,該路徑在兩邊都被同一個具名 volume 覆蓋,內容一致。
**因此 entrypoint 不再需要執行 `storage:link`**(與先前草稿不同,見 Decision 8 更新)。
### 6. deploy.yml 改為 build-then-upmigration/cache 挪到新 container 啟動後
新流程:
```
git reset --hard origin/master
docker compose build
docker compose up -d --remove-orphans
# 等 app healthy 後:
docker compose exec -T app php artisan migrate --force
docker compose exec -T app php artisan config:cache / route:cache / view:cache / event:cache
docker compose exec -T app php artisan l5-swagger:generate
```
`docker compose up -d` 本身會因為 image 內容改變觸發 app/scheduler/queue-worker/reverb/nginx 重新建立 containercompose 偵測 image ID 變化);`restart queue-worker` 這步可以拿掉,因為 `up -d` 已經會重建它。
**風險**`docker compose build` 若失敗(例如 composer 依賴解析錯誤),現行 `git reset --hard` 已經把主機檔案改到新版本,但 container 還在跑舊 image——這與現行流程風險相同(現行流程 composer install 失敗一樣會卡在中間態),不是本次變更新增的風險,但要在 tasks 裡明確測試「build 失敗時 compose 是否維持舊 container 運作」。
### 7. `.env` 改用 `env_file:` 注入環境變數,範圍只給 Laravel runtime 服務,entrypoint 移除「主機目錄」假設邏輯
**範圍修正**`env_file:` **只加在 `app`、`scheduler`、`queue-worker`、`reverb` 四個實際執行 Laravel/PHP 的服務****不加在 `nginx` 服務**。`nginx` 目前完全沒有 `environment:` 設定、不需要讀取任何 `.env` 內容(DB 密碼、`APP_KEY``AWS_*` 等)——它的職責只是服務 `web` 階段內建的靜態檔案並反向代理 `.php` 請求到 `app:9000`。給它 `.env` 存取權屬於不必要的機敏資訊暴露(最小權限原則),也容易誤導未來維護者以為 nginx 有讀取這些變數的需求。
四個 Laravel runtime 服務加上 `env_file:`**明確用 YAML list 格式**
```yaml
env_file:
- .env
```
(不用 `env_file: .env` 這種單一純量寫法,list 格式是本次統一採用的寫法,VPS 上該檔案由既有部署流程手動維護,路徑不變)。**文案修正**:`env_file:` 的作用是把該檔案內容**以環境變數形式**注入容器程序(等同 `docker run --env-file`),**不會在容器檔案系統裡生成一份 `.env` 檔案**。先前草稿「`.env` 已由 `env_file:` 於執行期注入」這句話容易誤讀成「容器內會有一個 `.env` 檔案」,已修正措辭。若程式碼中有任何地方依賴讀取實體 `.env` 檔案(而非 `getenv()`/`env()` 讀環境變數),需額外處理,但目前 Laravel 的 `env()` 本來就是讀環境變數,本次不受影響。
`docker-entrypoint.sh` 移除:
- `.env` 不存在則複製 `.env.example` + `key:generate``env_file:` 已確保環境變數存在,不需要容器內自動生成任何 `.env` 檔案)
- composer.lock 內容比對後才 `composer install`vendor 已在 image build 階段裝好,執行期不需要)
- 強制改寫 `DB_HOST=db` 那段 `preg_replace` 邏輯(改成直接在 `docker-compose.yml``environment:``.env` 裡設定,不用執行期改寫檔案)
### 8. 消除 deploy 與 entrypoint 的責任重疊
先前草稿讓 `docker-entrypoint.sh` 背景執行 migration/cache clear/swagger 生成,同時 `.gitea/workflows/deploy.yml` 又在 app healthy 後執行 migration/cache/swagger——兩邊職責重疊,且 entrypoint 這段邏輯會在 `app``scheduler``queue-worker` 三個服務啟動時各跑一次(三者都用同一個 entrypoint script,只是 `command:` 不同),造成 migration 被多個 container 併發嘗試執行的風險。改為明確切分:
- **deploy.yml 專責**migration、`config:cache`/`route:cache`/`view:cache`/`event:cache``l5-swagger:generate`——這些是「部署一次新版本」該做的事,只需執行一次,且只在 CI/CD 流程裡由人/流程明確觸發。
- **entrypoint 只保留啟動必要工作**:目錄與權限設定(`storage`/`bootstrap/cache`,因為 `storage-app-public` 具名 volume 掛載時會覆蓋 image 內建的權限,每次啟動都需要重新確保)。**`storage:link` 不再是 entrypoint 的工作**——依 Decision 5(修正版),符號連結已在 `app``web` 兩個階段 build 時各自獨立建立,執行期不需要再做任何事。
- 移除 entrypoint 裡「等待 MySQL」的背景迴圈:這段原本只是為了讓後續的 migration 有 DB 可用,migration 移到 deploy.yml 後,entrypoint 不再需要等 MySQL 才能完成它剩下的工作(目錄權限設定不需要 DB)。
**風險**:若日後有人在 `queue-worker``reverb` 啟動時就需要 migration 已完成(例如 migration 會改變 queue 用到的資料表結構),deploy.yml 的「migration 在所有服務都 `up -d` 之後才執行」可能有短暫窗口讓 worker 用到舊 schema 處理任務。緩解:這與現行流程風險相近(現行 migration 也是部署腳本晚於 container 啟動才跑),且 Laravel migration 多為向下相容設計(新增欄位等),非本次變更引入的新風險,故不在本次額外處理,僅記錄於此供未來參考。
## Risks / Trade-offs
- **部署變慢** → 每次 `docker compose build` 預期數十秒到數分鐘(視 composer install 網路狀況),取代現在近乎瞬間的 `git reset --hard`。緩解:Decision 1 的 layer 順序(composer manifests 先於程式碼)已利用 Docker layer cache 降低影響。
- **本機改 code 不再瞬間生效(若忘記用 override)** → 開發者若誤用 `docker compose -f docker-compose.yml up`(不帶 override)會觸發「改 code 要 rebuild」的體驗,容易誤判為 bug。緩解:README/CLAUDE.md 補充說明本機開發指令固定用 `docker compose up`(不加 `-f`),CI/部署腳本固定用 `-f docker-compose.yml`
- **`storage/app/public` 具名 volume 是過渡方案,非最終架構** → 之後正式切 S3 時需要額外一個變更去移除這個 volume 並確認舊檔案已遷移(`storage:migrate-to-s3` 已存在)。緩解:本次 design 已明確標注這是暫時方案,避免未來誤以為是最終狀態。
- **`docker compose build` 失敗時的中間態** → 現行流程本來就有「`git reset --hard` 已改主機檔案但 container 還沒套用」的窗口,本次不惡化此風險,但需在 tasks 裡加一項手動驗證。
## Migration Plan
1. 本機驗證:修改 `Dockerfile``docker-compose.yml`、調整既有已版控的 `compose.override.yml``docker-entrypoint.sh`,本機執行 `docker compose build && docker compose up -d` 確認所有服務正常啟動、`nc -z 127.0.0.1 9000` healthcheck 通過、既有測試套件全綠。
2. 本機驗證開發體驗:確認 `compose.override.yml` 生效後改 PHP 檔案不需 rebuild 即可看到效果(沿用現行 `kill -USR2 1` 或容器重啟機制)。
3. 更新 `.gitea/workflows/deploy.yml` 為 build-then-up 流程,先在非 master 分支(或手動觸發)驗證整個 CI 流程跑得通。
4. Merge 進 master,觀察 VPS 首次「build-then-up」部署,確認:image 建置成功、五個服務都用新 image 啟動、上傳檔案(`storage/app/public` volume)沒有遺失、既有功能(預約、聊天室、圖片上傳)正常。
5. **Rollback 策略**VPS 上舊的 `cfdive-platform` imagebuild 前的版本)不會被 `docker compose build` 自動刪除,若新版本異常可 `git revert` 該次 commit 並重新 `docker compose build && up -d` 回到前一版;因為程式碼與 vendor 都烘進 imagerollback 不再需要擔心「主機檔案已改但 vendor 版本不對」的情況(這其實是烘進 image 帶來的額外好處:rollback 更乾淨)。
## Open Questions
(本次規劃階段已收斂的問題不再列於此——volume 命名已定名為 `storage-app-public`,見 Decision 3`compose.override.yml` 版控狀態已查證澄清,見 Decision 2nginx 程式碼來源與 symlink 策略已定案,見 Decision 4、5。目前無待決問題。)
## 實作驗證後追加(2026-08-04
本機完整跑過 `docker compose build` + `up -d`(含 override 與 production 兩種模式)後,發現並修復三個規劃階段未預見的問題:
1. **`app` 服務的 `build:` 必須明確指定 `target: app`**`Dockerfile` 改 multi-stage 後,若 `build:` 未指定 `target:`Docker 預設建到 Dockerfile 中**最後一個** stage(本例是 `web`)。這代表 `cfdive-platform` image 若沒補上 `target: app`,實際內容會是 nginx 而非 php-fpm——`docker image inspect` 驗證過(entrypoint 顯示為 `nginx -g daemon off;`),屬於會讓整個 `app` container 完全無法運作的嚴重錯誤。已在 `docker-compose.yml``app` 服務 `build:` 區塊加上 `target: app` 修正,`nginx` 服務原本就有 `target: web` 不受影響。**這是本次多階段 Dockerfile 設計裡容易被忽略的一點:每一個引用同一份 multi-stage Dockerfile 的 compose service,都必須明確宣告自己要的 target,不能依賴預設值。**
2. **`.dockerignore` 需額外排除 `public/storage`**:本機開發環境長期有一個先前用 `php artisan storage:link`(Laravel 預設,非本次新增的相對路徑版本)建立的 `public/storage` 符號連結,指向絕對路徑 `/var/www/storage/app/public`。這個符號連結若留在 build context 內,會讓 Docker Desktop for Windows 在打包 build context 時因為 Windows 符號連結的檔案模式無法正確轉換而失敗(`archive/tar: unknown file mode`)。已加入 `.dockerignore`
3. **`env_file:``.env` 全部變數成為真實環境變數,破壞既有測試隔離機制**:這個專案先前已經因為「compose `environment:``DB_CONNECTION`/`DB_DATABASE` 設成真實環境變數,導致 PHPUnit 測試在容器內清空開發用 MySQL」踩過一次坑(2026-06-12),並在 `tests/bootstrap.php` 寫了強制覆蓋 `$_SERVER`/`$_ENV`/`putenv` 三處的修復。但那次修復**只覆蓋了 `DB_CONNECTION`/`DB_DATABASE` 兩個 key**,因為當時只有這兩個透過 compose `environment:` 洩漏成真實環境變數。這次 Decision 7 把 `env_file: .env` 套用到 `app` 服務後,`.env` 檔案裡**所有**變數都變成真實環境變數,包括與 `phpunit.xml` 宣告的 testing 值衝突的 `APP_ENV=local`(非 `testing`)、`CACHE_STORE=redis`(非 `array`)、`SESSION_DRIVER=database`(非 `array`)、`QUEUE_CONNECTION=database`(非 `sync`)、`MAIL_MAILER=smtp`(非 `array`)、`BCRYPT_ROUNDS=12`(非 `4`)——由於 Laravel 的 `env()` 透過 phpdotenv 優先讀 `$_SERVER`,這些真實環境變數蓋掉了 `phpunit.xml` 的宣告值,造成 9 個測試失敗(快取跨測試污染、意外走真實 SMTP mailer、`APP_ENV` 非 testing 導致例外處理行為不同等)。**修復方式是把既有 `tests/bootstrap.php` 的強制覆蓋清單,從只有 2 個 key 擴大到涵蓋 `phpunit.xml` 宣告的全部 11 個 key**,同時在 `phpunit.xml` 補上 `force="true"` 做自我說明(實際生效仍是 `tests/bootstrap.php``$_SERVER` 寫入,`force="true"` 本身不夠)。這不是本次變更引入的新架構問題,而是既有測試隔離機制的覆蓋範圍需要跟著 `env_file:` 的採用範圍同步擴大——**未來任何時候擴大 `env_file:` 涵蓋的變數範圍,都要重新檢查 `tests/bootstrap.php` 的強制覆蓋清單是否需要同步更新**。