Compare commits

..

2 Commits

Author SHA1 Message Date
a620906209 4b81166f25 Merge pull request 'chore(openspec): 歸檔 bake-image-remove-bind-mount + 同步主規格' (#60) from chore/archive-bake-image-remove-bind-mount into master
Deploy Production / deploy (push) Successful in 1m24s
Run Tests / test (push) Successful in 30s
Reviewed-on: #60
2026-08-04 09:44:41 +00:00
a620906209 05a116beee chore(openspec): 歸檔 bake-image-remove-bind-mount + 同步主規格
Run Tests / test (pull_request) Successful in 32s
VPS 部署與功能驗證皆已完成(tasks.md 7.7/7.8),change 移入
archive/2026-08-04-bake-image-remove-bind-mount。同步兩份 delta spec:
新建 image-baked-deployment 主規格(multi-stage build、symlink 建立、
nginx 獨立 build target、env_file 注入等 11 條 requirement);更新
scheduler-container 補上「不依賴 bind mount」的敘述與 scenario。
openspec validate --strict --specs 39/39 全過。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142LC1kQb8EyV59TjHDkhWS
2026-08-04 17:41:59 +08:00
8 changed files with 177 additions and 3 deletions
@@ -55,8 +55,8 @@
- [x] 7.4 [整合測試] 驗證 production 模式:`docker compose -f docker-compose.yml up -d`(不含 override)啟動後確認容器仍正常回應 API 請求——首頁 200、`/api/diving-offers` 回傳正確 JSON、migration/cache/swagger 全部成功
- [x] 7.5 [整合測試] 驗證上傳檔案持久性:建立測試檔案於 `storage/app/public/``docker compose up -d --force-recreate app nginx` 重建容器,確認該檔案透過 nginx `/storage/...` 仍可正常存取
- [x] 7.6 [整合測試] 驗證 `.env` 熱更新:修改 `.env``APP_NAME``docker compose up -d`(不 build)重建 container,確認容器內 `env('APP_NAME')` 讀到新值,測試後已還原
- [ ] 7.7 [整合測試] 在 Gitea Actions 上以手動觸發或測試分支驗證完整 `deploy.yml` 新流程跑得通,確認 build 失敗時舊 container 是否仍維持運作(design.md 已知風險項)——需 VPS/CI 環境,留給 Hank 執行
- [ ] 7.8 [整合測試] VPS 正式部署後,人工驗證預約、聊天室、課程圖片上傳、教練證照上傳等現有功能正常,並確認 `scheduler`/`queue-worker`/`reverb` 皆以新 image 啟動(`docker compose ps` 確認 image ID 一致)——需 VPS 環境,留給 Hank 執行
- [x] 7.7 [整合測試] 在 Gitea Actions 上以手動觸發或測試分支驗證完整 `deploy.yml` 新流程跑得通,確認 build 失敗時舊 container 是否仍維持運作(design.md 已知風險項)——Hank 確認 VPS 上 Gitea Actions 執行正常
- [x] 7.8 [整合測試] VPS 正式部署後,人工驗證預約、聊天室、課程圖片上傳、教練證照上傳等現有功能正常,並確認 `scheduler`/`queue-worker`/`reverb` 皆以新 image 啟動(`docker compose ps` 確認 image ID 一致)——Hank 確認 VPS 功能正常;`docker compose ps` 顯示 app/scheduler/queue-worker/reverb 皆為 `cfdive-platform` image、同批次(16 分鐘前)一起重建,nginx 為 `cfdive-nginx-web` 亦同批次重建,app healthcheck healthy
- [x] 7.9 [整合測試] 確認 entrypointdeploy 職責切分無重疊:`docker compose up -d` 啟動 `app`/`scheduler`/`queue-worker` 三個共用 entrypoint 的服務時,觀察其 log 不應出現 migration 或 `l5-swagger:generate` 執行紀錄,這些只應出現在 `deploy.yml` 執行紀錄裡——三個容器 log 都只有初始化與 php-fpm 啟動訊息
- [x] 7.10 [整合測試] 驗證 nginx 靜態資源獨立於 app:停掉 `app` container 後 nginx 仍能透過 HTTP 直接回應 `public/` 下的靜態檔案(`favicon.ico` 200)與 production 模式下 `/` 回應 200,確認不依賴 `app` container 是否存在或執行中
- [x] 7.11 [整合測試] 驗證 nginx 的 public/storage 符號連結獨立建立:`docker run --rm cfdive-platform` / `cfdive-nginx-web` 直接對 image(非透過 dev override 容器)執行 `readlink /var/www/public/storage`,兩者皆為 `../storage/app/public`,各自獨立存在於各自 image 內
@@ -0,0 +1,170 @@
# image-baked-deployment Specification
## Purpose
`cfdive-platform``cfdive-nginx-web` image 於 build 階段內嵌完整程式碼與 `vendor/`,讓 container 成為自足部署單位,不再依賴任何執行期 bind mount 或跨容器共享機制取得程式碼;部署流程改為 build-then-up`.env` 改以 `env_file:` 注入 Laravel runtime 服務。
## Requirements
### Requirement: Image build 內嵌完整程式碼與相依套件
`Dockerfile``app` 階段 SHALL 依序執行 `COPY composer.json composer.lock``composer install --no-dev --optimize-autoloader --no-scripts``COPY . /var/www`,使 build 完成的 `cfdive-platform` image 包含完整可執行程式碼(`app/``routes/``config/` 等)與 `vendor/`,不依賴任何執行期 bind mount 才能取得程式碼。composer manifests SHALL 先於全專案 COPY,以利用 Docker layer cache。
#### Scenario: 全新環境不需主機程式碼即可啟動
- **WHEN** 在一台全新機器上僅有 `cfdive-platform` image(無主機端 checkout 的程式碼目錄)
- **THEN** 以該 image 啟動的 container 可正常執行 `php artisan` 指令與服務既有 API 請求
#### Scenario: Build 完成後檔案權限正確
- **WHEN** `docker build` 執行完成
- **THEN** `/var/www/storage``/var/www/bootstrap/cache` 的擁有者為 `www-data`,且具備 PHP-FPM 寫入所需權限
#### Scenario: 只修改應用程式碼時 composer 層命中快取
- **WHEN** `composer.json``composer.lock` 內容未變動,僅應用程式碼變動後重新 `docker build`
- **THEN** `composer install` 所在的 layer 命中 Docker build cache,不重新執行
### Requirement: COPY . 之後補跑 composer 的 post-autoload 腳本
`composer.json``post-autoload-dump` 定義了 `Illuminate\Foundation\ComposerScripts::postAutoloadDump``php artisan package:discover --ansi`,這兩者依賴 Laravel 應用程式碼已存在才能執行,因此 `composer install` 階段 SHALL 使用 `--no-scripts` 跳過。`Dockerfile``app` 階段 SHALL 在 `COPY . /var/www` 之後補執行 `composer dump-autoload --optimize`(不重複加 `--no-dev`——該旗標已在 `composer install` 階段決定 vendor 內容,此處只是重新掃描 vendor 與新增的 `app/` 目錄產生 classmap)與 `php artisan package:discover --ansi`,確保 optimized classmap 包含專案自身 `App\` 命名空間下的類別、服務提供者自動發現快取正確產生。
#### Scenario: Image 內服務提供者自動發現快取存在
- **WHEN** `docker build` 執行完成
- **THEN** `/var/www/bootstrap/cache/packages.php``/var/www/bootstrap/cache/services.php` 存在且內容反映 `composer.json` 宣告的套件
#### Scenario: 專案自身類別可被 optimized autoloader 找到
- **WHEN** container 以烘好的 image 啟動並執行任一使用 `App\` 命名空間類別的 artisan 指令
- **THEN** 指令正常執行,不因 classmap 缺少專案自身類別而拋出 class not found 錯誤
### Requirement: public/storage 符號連結於 build 階段建立,app 與 web 兩階段各自獨立建立
`Dockerfile``app` 階段與 `web` 階段 SHALL **各自獨立**在其 `public/` 目錄就緒後執行 `ln -sfn ../storage/app/public /var/www/public/storage`(對應 `config/filesystems.php``links` 設定;`-n` 避免既有符號連結指向目錄時被誤判為目錄而巢狀建立),不依賴 `COPY --from=app` 隱性傳遞符號連結、也不依賴任何容器執行期指令(如 `php artisan storage:link`)。
#### Scenario: app 階段內存在符號連結
- **WHEN** `docker build` 執行完成
- **THEN** `cfdive-platform``app` 階段)image 內 `/var/www/public/storage` 是一個指向 `../storage/app/public` 的符號連結,不需要容器啟動後才產生
#### Scenario: web 階段獨立存在符號連結
- **WHEN** `docker build` 執行完成
- **THEN** `cfdive-nginx-web``web` 階段)image 內 `/var/www/public/storage` 也是一個指向 `../storage/app/public` 的符號連結,此連結由 `web` 階段自身的 `RUN ln -sfn` 指令建立,不依賴 `app` 階段的 build 產物或執行期狀態
#### Scenario: 重複執行符號連結指令具冪等性
- **WHEN** Docker layer cache 失效、`RUN ln -sfn ../storage/app/public /var/www/public/storage` 在同一路徑上重複執行
- **THEN** 該指令直接取代既有符號連結,不會在其中巢狀建立新的連結檔案
### Requirement: nginx 服務程式碼與靜態資源來自獨立 build target,不依賴執行期共享機制
`Dockerfile` SHALL 定義第二個 build 階段 `web``FROM nginx:alpine`),執行 `COPY --from=app /var/www/public /var/www/public` 取得 `app` 階段建置完成的 `public/` 目錄,並 `COPY docker/nginx/conf.d/ /etc/nginx/conf.d/` 帶入既有 nginx 設定。`docker-compose.yml``nginx` 服務 SHALL 改為從此 `web` target 本地建置(而非拉取官方 `nginx:alpine` image),不依賴任何執行期 volume 或其他跨容器共享機制取得程式碼。
#### Scenario: nginx image 內建 public 目錄
- **WHEN** 執行 `docker compose build``nginx` 服務對應 `web` target
- **THEN** 建置完成的 `nginx` image 內 `/var/www/public` 存在且內容與 `app` image build 時的 `public/` 一致
#### Scenario: 程式碼變更後 nginx 靜態資源同步更新
- **WHEN** 應用程式碼(含 `public/` 下的靜態資源)有變更並重新執行 `docker compose build`
- **THEN** `nginx` image 的 `/var/www/public` 內容反映最新的建置結果,不存在因具名 volume 只在首次掛載複製一次而導致的內容過期問題
#### Scenario: nginx 透過 HTTP 正確服務 public/ 下的靜態檔案
- **WHEN** `nginx` container 以 build 完成的 `web` image 啟動,對其發出一個 `public/` 目錄下既有靜態檔案(例如 `favicon.ico` 或前端建置後的靜態資源)的 HTTP 請求
- **THEN** nginx 直接回應該檔案內容(`try_files` 命中實體檔案),不需要透過 `app` container 轉發
#### Scenario: nginx 透過 HTTP 正確服務 public/storage 下的已上傳檔案
- **WHEN** `nginx` container 已掛載 `storage-app-public` 具名 volume 且該 volume 內存在一個已上傳檔案,對其發出 `/storage/{檔案路徑}` 的 HTTP 請求
- **THEN** nginx 透過 `public/storage` 符號連結解析到 `storage-app-public` volume 內的實際檔案並正確回應,不需要透過 `app` container 轉發
### Requirement: Build context 排除機敏與非必要檔案
`.dockerignore` SHALL 排除 `vendor/``node_modules/``.env``storage/app/public/*` 等路徑,確保 `COPY . /var/www` 不會把本機殘留檔案或機敏資訊(如 `.env` 內的資料庫密碼)複製進 image。
#### Scenario: .env 不出現在 image 內
- **WHEN** 主機 build context 目錄存在 `.env`(含資料庫密碼等機敏資訊)並執行 `docker build`
- **THEN** build 完成的 image 內 `/var/www/.env` 不存在
#### Scenario: 本機 vendor 不覆蓋 build 階段安裝結果
- **WHEN** 主機 build context 目錄存在本機已安裝的 `vendor/`(可能與 image 內 PHP 版本或平台不相容)
- **THEN** build 完成的 image 內 `vendor/` 內容完全來自 image build 階段的 `composer install`,不受主機 `vendor/` 內容影響
### Requirement: Production compose 不依賴主機 bind mount 取得程式碼
`docker-compose.yml`productionSHALL 不對 `app``nginx``scheduler``queue-worker``reverb` 任何一個服務定義 `./:/var/www` 這類將整個原始碼目錄掛進容器的 volume。
#### Scenario: 僅用 production compose 啟動時程式碼來自 image
- **WHEN** 執行 `docker compose -f docker-compose.yml up -d`
- **THEN** 五個服務容器內的 `/var/www` 內容與 image build 時內嵌的內容一致,即使主機端程式碼目錄被刪除,容器仍正常運作
### Requirement: 每個 compose service 明確指定 multi-stage build target
`docker-compose.yml` 中依賴這份 multi-stage `Dockerfile` 建置的每一個服務(`app``nginx`SHALL 在其 `build:` 設定明確指定 `target:``app` 服務指定 `target: app``nginx` 服務指定 `target: web`),不得省略。
#### Scenario: app 服務建置出的是 php-fpm image 而非 nginx
- **WHEN** 執行 `docker compose build app`
- **THEN** 建置完成的 `cfdive-platform` image 其 entrypoint/預設指令為啟動 php-fpm,而非 nginx`docker image inspect cfdive-platform` 確認 `Config.Cmd``["php-fpm"]`
#### Scenario: 省略 target 會建到 Dockerfile 最後一個 stage
- **WHEN** 某服務的 `build:` 未指定 `target:`
- **THEN** Docker 依規範建置到 Dockerfile 中定義的最後一個 stage,可能與該服務實際需要的 stage 不同——此為本次 multi-stage 化後所有服務都必須明確宣告 target 的原因
### Requirement: 本機開發環境透過 override 疊加 bind mount
`compose.override.yml` SHALL 為 `app``nginx``scheduler``queue-worker``reverb` 定義 `./:/var/www` bind mount,供本機開發時「改 code 立即生效」使用;此 override 檔案不影響 production compose 行為。
#### Scenario: 本機開發改 code 立即生效
- **WHEN** 開發者於本機執行 `docker compose up -d`(未指定 `-f`,自動疊加 override)並修改後端 PHP 檔案
- **THEN** 容器內看到的檔案內容即時反映主機端變更,不需重新 `docker compose build`
#### Scenario: VPS 部署不受 override 影響
- **WHEN** VPS 執行 `docker compose -f docker-compose.yml up -d --remove-orphans`(不吃 override
- **THEN** 容器程式碼完全來自 image,忽略主機上是否存在 `compose.override.yml`
### Requirement: 上傳檔案透過具名 volume 在 image 重建後保留
`docker-compose.yml` SHALL 定義具名 volume `storage-app-public`,掛載至 `app``nginx` 服務的 `/var/www/storage/app/public`(絕對路徑)路徑,確保使用者上傳的檔案(課程圖片、教練證照、聊天室圖片)在 image 重新 build 並重啟 container 後不遺失。
#### Scenario: Image 重建後既有上傳檔案仍可存取
- **WHEN** 執行 `docker compose build && docker compose up -d` 重建 image 並重啟 container
- **THEN** 重建前已上傳的課程圖片等檔案透過 `nginx` 仍可正常存取,內容與重建前一致
### Requirement: 部署流程改為 build-then-up,且為部署動作唯一執行位置
`.gitea/workflows/deploy.yml` SHALL 在 `git reset --hard origin/master` 之後執行 `docker compose build` 重新建置 image,再執行 `docker compose up -d` 套用新 image,取代原本「改寫主機檔案後於既有容器內執行 `composer install`」的流程。Migration、`config:cache``route:cache``view:cache``event:cache``l5-swagger:generate` SHALL 只在 `deploy.yml` 執行、且只在新 container 啟動且健康後才執行,`docker-entrypoint.sh` SHALL 不重複執行這些動作。
#### Scenario: 部署新版程式碼觸發 image 重建
- **WHEN** master 分支有新 commit 推送並觸發部署流程
- **THEN** 部署流程執行 `docker compose build``app`/`scheduler`/`queue-worker`/`reverb` 四個依賴 `cfdive-platform` image 的服務容器、以及 `nginx`(依賴 `cfdive-nginx-web` image)皆以新 image 重新建立
#### Scenario: Migration 在新 container 就緒後才執行
- **WHEN** `docker compose up -d` 執行完成
- **THEN** 部署流程等待 `app` container healthcheck 通過後,才執行 `php artisan migrate --force`
#### Scenario: Migration 與 cache 不在容器啟動時重複執行
- **WHEN** `app``scheduler``queue-worker` 三個共用同一 entrypoint 的服務各自啟動
- **THEN** 沒有任何一個容器在啟動過程中執行 migration、cache 系列 artisan 指令或 `l5-swagger:generate`——這些動作僅由 `deploy.yml` 執行一次
### Requirement: 環境變數透過 env_file 注入 Laravel runtime 服務,nginx 不注入
`docker-compose.yml``app``scheduler``queue-worker``reverb` 四個實際執行 Laravel/PHP 的服務 SHALL 使用 YAML list 格式的 `env_file:`(即 `env_file:` 後接 `- .env` 列表項,而非 `env_file: .env` 純量寫法)指向主機上的 `.env` 檔案路徑,將其內容以環境變數形式注入容器程序,不再透過 bind mount 整個目錄的方式讓容器讀到 `.env``nginx` 服務 SHALL **不**設定 `env_file:`——它不需要讀取任何 `.env` 內容,給予存取權屬於不必要的機敏資訊暴露。`env_file:` SHALL 只負責注入環境變數,容器內的檔案系統不會因此產生一份實體 `.env` 檔案。
#### Scenario: 更新 .env 後不需重建 image 即可套用
- **WHEN** 操作者修改主機上的 `.env` 內容並執行 `docker compose up -d`(不重新 build
- **THEN** `app``scheduler``queue-worker``reverb` 四個服務容器重新建立並讀取到更新後的環境變數
#### Scenario: 容器內不存在實體 .env 檔案
- **WHEN** container 以 `env_file:` 注入環境變數的方式啟動
- **THEN** 容器內 `/var/www/.env` 檔案不存在,程式讀取設定值皆透過環境變數(`env()`/`getenv()`)取得
#### Scenario: nginx 容器不具備 .env 內容存取權
- **WHEN** `nginx` container 啟動
- **THEN** 該容器的環境變數中不存在 `.env` 檔案內定義的變數(如 `DB_PASSWORD``APP_KEY`),因為 `nginx` 服務未設定 `env_file:`
### Requirement: Entrypoint 移除主機目錄假設邏輯
`docker-entrypoint.sh` SHALL 不再包含「`.env` 不存在時從 `.env.example` 複製並執行 `key:generate`」「依 `composer.lock` 內容比對決定是否重新 `composer install`」「強制以 `preg_replace` 改寫 `.env``DB_HOST` 值」這三段邏輯,因為 image 已在 build 階段完成 vendor 安裝、環境變數已由 `env_file:` 於執行期注入。
#### Scenario: Container 啟動不再執行執行期 composer install
- **WHEN** container 以烘好程式碼的 image 啟動
- **THEN** entrypoint 不執行任何 `composer install` 指令,`vendor/` 內容與 image build 時完全一致
#### Scenario: Container 啟動不再自動產生 .env
- **WHEN** container 啟動且未透過 `env_file:` 提供有效環境變數
- **THEN** entrypoint 不自動複製 `.env.example` 或執行 `php artisan key:generate`,由部署流程確保 `.env` 存在為前提條件
#### Scenario: Container 啟動不再改寫 .env 檔案內容
- **WHEN** container 啟動
- **THEN** entrypoint 不執行任何修改 `.env` 檔案內容的指令,`DB_HOST` 等設定值一律由 `docker-compose.yml``environment:``.env` 提供
### Requirement: Entrypoint 只保留啟動必要工作
`docker-entrypoint.sh` SHALL 只保留目錄與權限設定(`storage``bootstrap/cache`),不再包含等待 MySQL 的背景迴圈、migration、cache clear 系列指令、`l5-swagger:generate``storage:link`symlink 已於 image build 階段建立,見「public/storage 符號連結於 build 階段建立」requirement)。
#### Scenario: Entrypoint 不等待 MySQL
- **WHEN** container 啟動且 MySQL 尚未就緒
- **THEN** entrypoint 不因此阻塞或執行任何等待迴圈,php-fpm 正常啟動
#### Scenario: Entrypoint 不執行 storage:link
- **WHEN** container 啟動且 `storage-app-public` 具名 volume 已掛載
- **THEN** entrypoint 不執行任何 `storage:link` 相關指令,`/var/www/public/storage` 符號連結已存在於 image 內容中
+5 -1
View File
@@ -7,7 +7,7 @@ Laravel Scheduler 以獨立 container 執行,取代 app container 內的 cron
## Requirements
### Requirement: Scheduler 以獨立 container 執行
`docker-compose.yml` SHALL 定義 `scheduler` service,使用 `cfdive-platform` image,以 `php artisan schedule:work` 前台輪詢方式執行 Laravel Scheduler,取代原本在 app container 內的 cron daemon。
`docker-compose.yml` SHALL 定義 `scheduler` service,使用 `cfdive-platform` image(其中已於 build 階段內嵌完整程式碼,不依賴 `./:/var/www` bind mount,以 `php artisan schedule:work` 前台輪詢方式執行 Laravel Scheduler,取代原本在 app container 內的 cron daemon。
#### Scenario: Scheduler container 隨 app 啟動
- **WHEN** 執行 `docker compose up -d``app` container 已 healthy
@@ -17,6 +17,10 @@ Laravel Scheduler 以獨立 container 執行,取代 app container 內的 cron
- **WHEN** `app` container 重啟中(unhealthy
- **THEN** `scheduler` container 依 `depends_on` 等待 `app` healthy 後才啟動,避免 scheduler 在 PHP-FPM 未就緒時執行任務
#### Scenario: Scheduler 不依賴主機 bind mount 取得程式碼
- **WHEN** 執行 `docker compose -f docker-compose.yml up -d`production,不含 override
- **THEN** `scheduler` container 執行的排程任務程式碼完全來自 `cfdive-platform` image build 時內嵌的內容,與主機端程式碼目錄是否存在無關
### Requirement: App container 不再包含 cron daemon
`Dockerfile` SHALL 不安裝 `cron` 套件,`docker-entrypoint.sh` SHALL 不執行 `service cron start`,確保 app container 職責單一(只跑 PHP-FPM)。