Files
CFDivePlatform/openspec/specs/image-baked-deployment/spec.md
T
a620906209 05a116beee
Run Tests / test (pull_request) Successful in 32s
chore(openspec): 歸檔 bake-image-remove-bind-mount + 同步主規格
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

15 KiB
Raw Blame History

image-baked-deployment Specification

Purpose

cfdive-platformcfdive-nginx-web image 於 build 階段內嵌完整程式碼與 vendor/,讓 container 成為自足部署單位,不再依賴任何執行期 bind mount 或跨容器共享機制取得程式碼;部署流程改為 build-then-up.env 改以 env_file: 注入 Laravel runtime 服務。

Requirements

Requirement: Image build 內嵌完整程式碼與相依套件

Dockerfileapp 階段 SHALL 依序執行 COPY composer.json composer.lockcomposer install --no-dev --optimize-autoloader --no-scriptsCOPY . /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.jsoncomposer.lock 內容未變動,僅應用程式碼變動後重新 docker build
  • THEN composer install 所在的 layer 命中 Docker build cache,不重新執行

Requirement: COPY . 之後補跑 composer 的 post-autoload 腳本

composer.jsonpost-autoload-dump 定義了 Illuminate\Foundation\ComposerScripts::postAutoloadDumpphp artisan package:discover --ansi,這兩者依賴 Laravel 應用程式碼已存在才能執行,因此 composer install 階段 SHALL 使用 --no-scripts 跳過。Dockerfileapp 階段 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 兩階段各自獨立建立

Dockerfileapp 階段與 web 階段 SHALL 各自獨立在其 public/ 目錄就緒後執行 ln -sfn ../storage/app/public /var/www/public/storage(對應 config/filesystems.phplinks 設定;-n 避免既有符號連結指向目錄時被誤判為目錄而巢狀建立),不依賴 COPY --from=app 隱性傳遞符號連結、也不依賴任何容器執行期指令(如 php artisan storage:link)。

Scenario: app 階段內存在符號連結

  • WHEN docker build 執行完成
  • THEN cfdive-platformapp 階段)image 內 /var/www/public/storage 是一個指向 ../storage/app/public 的符號連結,不需要容器啟動後才產生

Scenario: web 階段獨立存在符號連結

  • WHEN docker build 執行完成
  • THEN cfdive-nginx-webweb 階段)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 階段 webFROM 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.ymlnginx 服務 SHALL 改為從此 web target 本地建置(而非拉取官方 nginx:alpine image),不依賴任何執行期 volume 或其他跨容器共享機制取得程式碼。

Scenario: nginx image 內建 public 目錄

  • WHEN 執行 docker compose buildnginx 服務對應 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/.envstorage/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.ymlproductionSHALL 不對 appnginxschedulerqueue-workerreverb 任何一個服務定義 ./:/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 建置的每一個服務(appnginxSHALL 在其 build: 設定明確指定 target:app 服務指定 target: appnginx 服務指定 target: web),不得省略。

Scenario: app 服務建置出的是 php-fpm image 而非 nginx

  • WHEN 執行 docker compose build app
  • THEN 建置完成的 cfdive-platform image 其 entrypoint/預設指令為啟動 php-fpm,而非 nginxdocker 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 為 appnginxschedulerqueue-workerreverb 定義 ./:/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,掛載至 appnginx 服務的 /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:cacheroute:cacheview:cacheevent:cachel5-swagger:generate SHALL 只在 deploy.yml 執行、且只在新 container 啟動且健康後才執行,docker-entrypoint.sh SHALL 不重複執行這些動作。

Scenario: 部署新版程式碼觸發 image 重建

  • WHEN master 分支有新 commit 推送並觸發部署流程
  • THEN 部署流程執行 docker compose buildapp/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 appschedulerqueue-worker 三個共用同一 entrypoint 的服務各自啟動
  • THEN 沒有任何一個容器在啟動過程中執行 migration、cache 系列 artisan 指令或 l5-swagger:generate——這些動作僅由 deploy.yml 執行一次

Requirement: 環境變數透過 env_file 注入 Laravel runtime 服務,nginx 不注入

docker-compose.ymlappschedulerqueue-workerreverb 四個實際執行 Laravel/PHP 的服務 SHALL 使用 YAML list 格式的 env_file:(即 env_file: 後接 - .env 列表項,而非 env_file: .env 純量寫法)指向主機上的 .env 檔案路徑,將其內容以環境變數形式注入容器程序,不再透過 bind mount 整個目錄的方式讓容器讀到 .envnginx 服務 SHALL 設定 env_file:——它不需要讀取任何 .env 內容,給予存取權屬於不必要的機敏資訊暴露。env_file: SHALL 只負責注入環境變數,容器內的檔案系統不會因此產生一份實體 .env 檔案。

Scenario: 更新 .env 後不需重建 image 即可套用

  • WHEN 操作者修改主機上的 .env 內容並執行 docker compose up -d(不重新 build
  • THEN appschedulerqueue-workerreverb 四個服務容器重新建立並讀取到更新後的環境變數

Scenario: 容器內不存在實體 .env 檔案

  • WHEN container 以 env_file: 注入環境變數的方式啟動
  • THEN 容器內 /var/www/.env 檔案不存在,程式讀取設定值皆透過環境變數(env()/getenv())取得

Scenario: nginx 容器不具備 .env 內容存取權

  • WHEN nginx container 啟動
  • THEN 該容器的環境變數中不存在 .env 檔案內定義的變數(如 DB_PASSWORDAPP_KEY),因為 nginx 服務未設定 env_file:

Requirement: Entrypoint 移除主機目錄假設邏輯

docker-entrypoint.sh SHALL 不再包含「.env 不存在時從 .env.example 複製並執行 key:generate」「依 composer.lock 內容比對決定是否重新 composer install」「強制以 preg_replace 改寫 .envDB_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.ymlenvironment:.env 提供

Requirement: Entrypoint 只保留啟動必要工作

docker-entrypoint.sh SHALL 只保留目錄與權限設定(storagebootstrap/cache),不再包含等待 MySQL 的背景迴圈、migration、cache clear 系列指令、l5-swagger:generatestorage:linksymlink 已於 image build 階段建立,見「public/storage 符號連結於 build 階段建立」requirement)。

Scenario: Entrypoint 不等待 MySQL

  • WHEN container 啟動且 MySQL 尚未就緒
  • THEN entrypoint 不因此阻塞或執行任何等待迴圈,php-fpm 正常啟動
  • WHEN container 啟動且 storage-app-public 具名 volume 已掛載
  • THEN entrypoint 不執行任何 storage:link 相關指令,/var/www/public/storage 符號連結已存在於 image 內容中