diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0101c60 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +vendor/ +node_modules/ +.env +.env.backup +.env.production +storage/app/public/* +public/storage +frontend/node_modules/ +frontend/dist/ +.git/ +.claude/ +.phpunit.cache/ +.phpunit.result.cache diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8aaed47..6e972d0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -15,10 +15,30 @@ jobs: flock /tmp/cfdive-deploy.lock git fetch origin git reset --hard origin/master - - name: Install Composer dependencies + - name: Build images run: | cd /root/myproject/CFDivePlatform - docker compose exec -T app composer install --no-dev --optimize-autoloader + docker compose build + + - name: Start containers with new images + run: | + cd /root/myproject/CFDivePlatform + docker compose up -d --remove-orphans + + - name: Wait for app container healthy + run: | + cd /root/myproject/CFDivePlatform + for i in $(seq 1 30); do + status=$(docker inspect --format='{{.State.Health.Status}}' cfdive-app 2>/dev/null || echo "starting") + if [ "$status" = "healthy" ]; then + echo "✅ app container healthy" + exit 0 + fi + echo "⏳ waiting for app healthy... ($status)" + sleep 2 + done + echo "❌ app container did not become healthy in time" + exit 1 - name: Run migrations run: | @@ -38,11 +58,6 @@ jobs: cd /root/myproject/CFDivePlatform docker compose exec -T app php artisan l5-swagger:generate - - name: Restart queue worker - run: | - cd /root/myproject/CFDivePlatform - docker compose restart queue-worker - - name: Rebuild frontend (if changed) run: | cd /root/myproject/CFDivePlatform diff --git a/Dockerfile b/Dockerfile index 5bc7937..699d455 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # 使用官方 PHP 8.2 FPM 鏡像作為基礎 -FROM php:8.2-fpm +FROM php:8.2-fpm AS app # 安裝系統依賴 # 1. 更新套件列表並安裝必要的套件 @@ -43,6 +43,31 @@ RUN chown -R www-data:www-data /var/www RUN mkdir -p /var/www/storage /var/www/bootstrap/cache RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache +# composer manifests 先於全專案 COPY,讓 Docker layer cache 在 composer.json/composer.lock +# 未變動時可以直接命中,不必每次程式碼變動都重跑 composer install +COPY composer.json composer.lock /var/www/ +# --no-scripts:此時 app/、artisan 尚未存在,composer 的 post-autoload-dump hook +# (會呼叫 artisan package:discover)此時執行會失敗,故跳過,待 COPY . 之後手動補跑 +RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts + +# 複製完整專案程式碼 +COPY . /var/www + +# COPY 進來的檔案預設 owner 是 root,需重新套用權限 +RUN chown -R www-data:www-data /var/www \ + && chmod -R 775 /var/www/storage /var/www/bootstrap/cache + +# 補上 --no-scripts 跳過的 composer post-autoload-dump 動作: +# dump-autoload 重新掃描此時才存在的 app/ 目錄以產生完整 classmap, +# package:discover 產生服務提供者自動發現快取 +RUN composer dump-autoload --optimize \ + && php artisan package:discover --ansi + +# 對應 config/filesystems.php 的 links 設定,於 build 階段建立符號連結 +# (純檔案系統操作,不需要 Laravel 執行期或資料庫連線) +# -n 避免既有符號連結指向目錄時被誤判為目錄而巢狀建立,確保重跑此指令時是冪等取代 +RUN ln -sfn ../storage/app/public /var/www/public/storage + # 複製自定義的 PHP 配置文件 # 這個文件包含 PHP 運行時的配置選項 COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini @@ -59,3 +84,16 @@ ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] # 設置默認的容器命令 # 這將在 ENTRYPOINT 執行後運行 CMD ["php-fpm"] + +# nginx 靜態資源與反向代理階段 +# 不依賴任何執行期 volume 或跨容器共享機制取得程式碼—— +# public/ 目錄與符號連結都在 build 階段從 app 階段取得/建立 +FROM nginx:alpine AS web + +COPY --from=app /var/www/public /var/www/public + +# 獨立建立符號連結,不依賴上面 COPY --from 隱性帶入 app 階段建立的符號連結 +# (兩階段各自明確宣告,避免日後複製方式調整導致符號連結無聲消失) +RUN ln -sfn ../storage/app/public /var/www/public/storage + +COPY docker/nginx/conf.d/ /etc/nginx/conf.d/ diff --git a/compose.override.yml b/compose.override.yml index b41be21..345720e 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -1,4 +1,26 @@ services: + # 本機開發用:疊加 bind mount,維持「改 code 立即生效」的體驗 + # production(docker-compose.yml 單獨執行,不吃這份 override)不受影響 + app: + volumes: + - ./:/var/www + + nginx: + volumes: + - ./:/var/www + + scheduler: + volumes: + - ./:/var/www + + queue-worker: + volumes: + - ./:/var/www + + reverb: + volumes: + - ./:/var/www + phpmyadmin: image: phpmyadmin/phpmyadmin container_name: cfdive-phpmyadmin diff --git a/docker-compose.yml b/docker-compose.yml index fa7bf6d..c6b2628 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,36 +6,32 @@ services: build: context: . # 使用當前目錄作為構建上下文 dockerfile: Dockerfile # 指定 Dockerfile 路徑 + target: app # 明確指定 build target,避免預設建到最後一個 stage(web/nginx) image: cfdive-platform # 構建的鏡像名稱 container_name: cfdive-app # 容器名稱 restart: unless-stopped # 自動重啟策略:除非手動停止,否則自動重啟 working_dir: /var/www/ # 工作目錄 - - # 卷掛載:將本地代碼掛載到容器中 + + # 上傳檔案在 image 重建後仍保留(過渡方案,待正式切 S3 後可移除) volumes: - - ./:/var/www # 本地代碼目錄掛載到容器中的 /var/www - - # 環境變數:設置 Laravel 數據庫連接 - environment: - - DB_CONNECTION=mysql # 數據庫類型 - - DB_HOST=db # 數據庫主機名 - - DB_PORT=3306 # 數據庫端口 - - DB_DATABASE=${DB_DATABASE:-CFDivePlatform} # 數據庫名稱 - - DB_USERNAME=${DB_USERNAME:-cfdiveuser} # 數據庫用戶名 - - DB_PASSWORD=${DB_PASSWORD} # 數據庫密碼 - + - storage-app-public:/var/www/storage/app/public + + # 環境變數:以 env_file 注入 .env 內容,不會在容器內生成實體 .env 檔案 + env_file: + - .env + # 網絡配置 networks: - cfdive-network # 連接到自定義網絡 - proxy_net - + # 依賴關係:確保 db 和 redis 服務先啟動 depends_on: db: # 依賴 db 服務 condition: service_healthy # 等待數據庫健康檢查通過 redis: # 依賴 redis 服務 condition: service_started # 等待服務啟動 - + # 健康檢查配置 healthcheck: test: ["CMD-SHELL", "nc -z 127.0.0.1 9000 || exit 1"] @@ -45,14 +41,19 @@ services: start_period: 40s nginx: - image: nginx:alpine + # 從 Dockerfile 的 web 階段本地建置,取得 public/ 靜態資源, + # 不再拉取官方 nginx:alpine image、也不依賴任何執行期共享機制取得程式碼 + build: + context: . + dockerfile: Dockerfile + target: web + image: cfdive-nginx-web container_name: cfdive-nginx restart: unless-stopped ports: - "127.0.0.1:8080:80" volumes: - - ./:/var/www - - ./docker/nginx/conf.d/:/etc/nginx/conf.d/ + - storage-app-public:/var/www/storage/app/public networks: - cfdive-network - proxy_net @@ -116,15 +117,8 @@ services: restart: unless-stopped working_dir: /var/www/ command: php artisan schedule:work - volumes: - - ./:/var/www - environment: - - DB_CONNECTION=mysql - - DB_HOST=db - - DB_PORT=3306 - - DB_DATABASE=${DB_DATABASE:-CFDivePlatform} - - DB_USERNAME=${DB_USERNAME:-cfdiveuser} - - DB_PASSWORD=${DB_PASSWORD} + env_file: + - .env networks: - cfdive-network depends_on: @@ -137,15 +131,8 @@ services: restart: unless-stopped working_dir: /var/www/ command: php artisan queue:work --sleep=3 --tries=3 --timeout=60 - volumes: - - ./:/var/www - environment: - - DB_CONNECTION=mysql - - DB_HOST=db - - DB_PORT=3306 - - DB_DATABASE=${DB_DATABASE:-CFDivePlatform} - - DB_USERNAME=${DB_USERNAME:-cfdiveuser} - - DB_PASSWORD=${DB_PASSWORD} + env_file: + - .env networks: - cfdive-network depends_on: @@ -175,22 +162,8 @@ services: entrypoint: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"] ports: - "127.0.0.1:8085:8080" - volumes: - - ./:/var/www - environment: - - APP_KEY=${APP_KEY} - - REVERB_APP_ID=${REVERB_APP_ID} - - REVERB_APP_KEY=${REVERB_APP_KEY} - - REVERB_APP_SECRET=${REVERB_APP_SECRET} - - REVERB_HOST=reverb - - REVERB_PORT=8080 - - REDIS_HOST=redis - - DB_CONNECTION=mysql - - DB_HOST=db - - DB_PORT=3306 - - DB_DATABASE=${DB_DATABASE:-CFDivePlatform} - - DB_USERNAME=${DB_USERNAME:-cfdiveuser} - - DB_PASSWORD=${DB_PASSWORD} + env_file: + - .env networks: - cfdive-network - proxy_net @@ -209,3 +182,5 @@ volumes: driver: local redis-data: driver: local + storage-app-public: + driver: local diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index 1824c48..8d728b5 100644 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -4,60 +4,11 @@ set -e echo "=== CFDivePlatform 容器初始化開始 ===" # 確保目錄與權限(php-fpm 啟動前必須完成) +# storage-app-public 具名 volume 掛載時會覆蓋 image 內建的權限,每次啟動都需要重新確保 [ ! -d "/var/www/storage" ] && mkdir -p /var/www/storage [ ! -d "/var/www/bootstrap/cache" ] && mkdir -p /var/www/bootstrap/cache chown -R www-data:www-data /var/www chmod -R 775 /var/www/storage /var/www/bootstrap/cache -# 確保 .env 存在 -if [ ! -f .env ]; then - cp .env.example .env - php artisan key:generate -fi - -# 強制 DB_HOST=db(Docker service name,不能用 localhost) -php -r " -\$env = file_get_contents('/var/www/.env'); -\$env = preg_replace('/^DB_HOST=.*$/m', 'DB_HOST=db', \$env); -file_put_contents('/var/www/.env', \$env); -" - -# Composer 依賴(vendor 不存在或 lock 內容變更時才裝) -# 用內容比對而非 mtime:git checkout/pull 會更新 composer.json 的 mtime, -# mtime 比對會讓每次分支操作後的開機都重跑 autoload 生成(bind mount 上耗時數分鐘、期間全站 502) -if [ -f "composer.lock" ] && { [ ! -d "vendor" ] || ! cmp -s composer.lock vendor/.composer.lock.installed; }; then - composer install --no-scripts --optimize-autoloader - cp composer.lock vendor/.composer.lock.installed -fi - -# 背景執行:等 MySQL → migration → cache clear → storage link → swagger -# php-fpm 不等這些完成就先啟動,避免重啟時 CORS 502 -( - echo "⏳ [背景] 等待 MySQL..." - COUNT=0 - until mysqladmin ping -h"db" -u"${DB_USERNAME:-cfdiveuser}" -p"${DB_PASSWORD}" --silent 2>/dev/null || [ $COUNT -ge 30 ]; do - sleep 2 - COUNT=$((COUNT+1)) - done - - echo "🗄️ [背景] 執行 migration..." - php artisan migrate --force || echo "⚠️ migration 失敗" - - echo "🧹 [背景] 清除 Laravel 緩存..." - php artisan config:clear || true - php artisan cache:clear || true - php artisan route:clear || true - php artisan view:clear || true - - echo "🔗 [背景] storage:link..." - php artisan storage:link --force || true - - if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then - php artisan l5-swagger:generate || true - fi - - echo "✅ [背景] 初始化完成" -) & - echo "🚀 啟動 php-fpm..." exec "$@" diff --git a/openspec/changes/bake-image-remove-bind-mount/design.md b/openspec/changes/bake-image-remove-bind-mount/design.md index afaf049..f65c30f 100644 --- a/openspec/changes/bake-image-remove-bind-mount/design.md +++ b/openspec/changes/bake-image-remove-bind-mount/design.md @@ -134,3 +134,11 @@ env_file: ## Open Questions (本次規劃階段已收斂的問題不再列於此——volume 命名已定名為 `storage-app-public`,見 Decision 3;`compose.override.yml` 版控狀態已查證澄清,見 Decision 2;nginx 程式碼來源與 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` 的強制覆蓋清單是否需要同步更新**。 diff --git a/openspec/changes/bake-image-remove-bind-mount/proposal.md b/openspec/changes/bake-image-remove-bind-mount/proposal.md index 0aeb6c6..8b3e693 100644 --- a/openspec/changes/bake-image-remove-bind-mount/proposal.md +++ b/openspec/changes/bake-image-remove-bind-mount/proposal.md @@ -21,7 +21,7 @@ ## Impact -- **受影響檔案**:`Dockerfile`(改為 multi-stage:`app` + `web`)、`.dockerignore`(新增)、`docker-compose.yml`、`.gitea/workflows/deploy.yml`、`docker/php/docker-entrypoint.sh` +- **受影響檔案**:`Dockerfile`(改為 multi-stage:`app` + `web`)、`.dockerignore`(新增,含 `public/storage` 排除本機既有符號連結)、`docker-compose.yml`(`app` 服務需明確 `target: app`,否則預設建到最後一個 stage)、`.gitea/workflows/deploy.yml`、`docker/php/docker-entrypoint.sh`、`compose.override.yml`;另因 `env_file:` 讓 `.env` 全部變數成為真實環境變數而牽動 `tests/bootstrap.php`、`phpunit.xml`(擴大既有測試環境隔離的強制覆蓋範圍,非新增機制,詳見 design.md 實作驗證後追加) - **受影響服務**:app、nginx(改為本地 build 而非拉取 `nginx:alpine`)、scheduler、queue-worker、reverb(全部五個掛 bind mount 的服務) - **受影響流程**:VPS 部署流程(Gitea Actions self-hosted runner)、本機開發流程(開發者需改用 `docker compose build` 才能讓程式碼變動生效,或另外規劃開發環境仍用 bind mount 的 override 方案) - **不受影響**:`file-storage-s3`(本次不強制切換到 S3,上傳檔案改走具名 volume 過渡)、`compose-cloud-baseline`(healthcheck/開發工具隔離邏輯不變) diff --git a/openspec/changes/bake-image-remove-bind-mount/specs/image-baked-deployment/spec.md b/openspec/changes/bake-image-remove-bind-mount/specs/image-baked-deployment/spec.md index 243ff9e..ab06316 100644 --- a/openspec/changes/bake-image-remove-bind-mount/specs/image-baked-deployment/spec.md +++ b/openspec/changes/bake-image-remove-bind-mount/specs/image-baked-deployment/spec.md @@ -78,6 +78,17 @@ - **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 行為。 diff --git a/openspec/changes/bake-image-remove-bind-mount/tasks.md b/openspec/changes/bake-image-remove-bind-mount/tasks.md index ce4bb54..cd384c3 100644 --- a/openspec/changes/bake-image-remove-bind-mount/tasks.md +++ b/openspec/changes/bake-image-remove-bind-mount/tasks.md @@ -1,25 +1,25 @@ ## 1. Dockerfile 改為 multi-stage:app 階段內嵌程式碼與相依套件 -- [ ] 1.1 [後端] `Dockerfile`:第一階段命名為 `FROM php:8.2-fpm AS app`(既有內容套用此階段名稱,供後續 `web` 階段 `COPY --from=app` 使用) -- [ ] 1.2 [後端] `Dockerfile`:在 `docker-php-ext-install` 之後、`COPY docker/php/local.ini` 之前,先 `COPY composer.json composer.lock /var/www/` 並執行 `composer install --no-dev --optimize-autoloader --no-interaction --no-scripts`(利用 layer cache,composer 檔案未變動時不重跑;`--no-scripts` 是必要的,因為此時 `app/`、`artisan` 尚未存在,composer 的 `post-autoload-dump` hook 會失敗) -- [ ] 1.3 [後端] `Dockerfile`:新增 `COPY . /var/www`(放在 composer install 之後),並在其後重新執行 `chown -R www-data:www-data /var/www` 與 `chmod -R 775 storage bootstrap/cache`(COPY 進來的檔案預設 owner 是 root) -- [ ] 1.4 [後端] `Dockerfile`:`COPY . /var/www` 之後補執行 `composer dump-autoload --optimize`(不重複加 `--no-dev`,該旗標已由前面的 `composer install --no-dev` 決定 vendor 內容,這裡只是重新掃描產生 classmap)與 `php artisan package:discover --ansi`(補上 `--no-scripts` 跳過的服務提供者自動發現,確認 `bootstrap/cache/packages.php`/`services.php` 正確產生) -- [ ] 1.5 [後端] `Dockerfile`:`COPY . /var/www` 之後執行 `RUN ln -sfn ../storage/app/public /var/www/public/storage`(`-n` 避免既有符號連結指向目錄時被誤判巢狀建立),在 build 階段建立 `public/storage` 符號連結(對應 `config/filesystems.php` 的 `links` 設定),不依賴任何執行期 artisan 指令 -- [ ] 1.6 [後端] 新增 `.dockerignore`(目前不存在,屬本次必要項目非優化):排除 `vendor/`、`node_modules/`、`.env`、`storage/app/public/*`,避免機敏資訊(`.env` 內資料庫密碼)或本機殘留檔案被 `COPY . /var/www` 複製進 image +- [x] 1.1 [後端] `Dockerfile`:第一階段命名為 `FROM php:8.2-fpm AS app`(既有內容套用此階段名稱,供後續 `web` 階段 `COPY --from=app` 使用) +- [x] 1.2 [後端] `Dockerfile`:在 `docker-php-ext-install` 之後、`COPY docker/php/local.ini` 之前,先 `COPY composer.json composer.lock /var/www/` 並執行 `composer install --no-dev --optimize-autoloader --no-interaction --no-scripts`(利用 layer cache,composer 檔案未變動時不重跑;`--no-scripts` 是必要的,因為此時 `app/`、`artisan` 尚未存在,composer 的 `post-autoload-dump` hook 會失敗) +- [x] 1.3 [後端] `Dockerfile`:新增 `COPY . /var/www`(放在 composer install 之後),並在其後重新執行 `chown -R www-data:www-data /var/www` 與 `chmod -R 775 storage bootstrap/cache`(COPY 進來的檔案預設 owner 是 root) +- [x] 1.4 [後端] `Dockerfile`:`COPY . /var/www` 之後補執行 `composer dump-autoload --optimize`(不重複加 `--no-dev`,該旗標已由前面的 `composer install --no-dev` 決定 vendor 內容,這裡只是重新掃描產生 classmap)與 `php artisan package:discover --ansi`(補上 `--no-scripts` 跳過的服務提供者自動發現,確認 `bootstrap/cache/packages.php`/`services.php` 正確產生) +- [x] 1.5 [後端] `Dockerfile`:`COPY . /var/www` 之後執行 `RUN ln -sfn ../storage/app/public /var/www/public/storage`(`-n` 避免既有符號連結指向目錄時被誤判巢狀建立),在 build 階段建立 `public/storage` 符號連結(對應 `config/filesystems.php` 的 `links` 設定),不依賴任何執行期 artisan 指令 +- [x] 1.6 [後端] 新增 `.dockerignore`(目前不存在,屬本次必要項目非優化):排除 `vendor/`、`node_modules/`、`.env`、`storage/app/public/*`,避免機敏資訊(`.env` 內資料庫密碼)或本機殘留檔案被 `COPY . /var/www` 複製進 image ## 2. Dockerfile 新增 web 階段(nginx 程式碼來源) -- [ ] 2.1 [後端] `Dockerfile`:新增第二階段 `FROM nginx:alpine AS web` -- [ ] 2.2 [後端] `Dockerfile`:`web` 階段執行 `COPY --from=app /var/www/public /var/www/public`,取得 `app` 階段建置完成的 `public/` 目錄 -- [ ] 2.3 [後端] `Dockerfile`:`web` 階段**再次獨立執行** `RUN ln -sfn ../storage/app/public /var/www/public/storage`(不依賴 2.2 的 `COPY --from` 隱性帶入 1.5 建立的符號連結——兩階段各自明確宣告,避免日後若複製方式調整導致符號連結無聲消失) -- [ ] 2.4 [後端] `Dockerfile`:`web` 階段執行 `COPY docker/nginx/conf.d/ /etc/nginx/conf.d/`,帶入既有 nginx 設定(`docker/nginx/conf.d/app.conf` 的 `root /var/www/public;` 設定不需更動,`SCRIPT_FILENAME` 路徑在 app/nginx 兩個容器間仍一致) +- [x] 2.1 [後端] `Dockerfile`:新增第二階段 `FROM nginx:alpine AS web` +- [x] 2.2 [後端] `Dockerfile`:`web` 階段執行 `COPY --from=app /var/www/public /var/www/public`,取得 `app` 階段建置完成的 `public/` 目錄 +- [x] 2.3 [後端] `Dockerfile`:`web` 階段**再次獨立執行** `RUN ln -sfn ../storage/app/public /var/www/public/storage`(不依賴 2.2 的 `COPY --from` 隱性帶入 1.5 建立的符號連結——兩階段各自明確宣告,避免日後若複製方式調整導致符號連結無聲消失) +- [x] 2.4 [後端] `Dockerfile`:`web` 階段執行 `COPY docker/nginx/conf.d/ /etc/nginx/conf.d/`,帶入既有 nginx 設定(`docker/nginx/conf.d/app.conf` 的 `root /var/www/public;` 設定不需更動,`SCRIPT_FILENAME` 路徑在 app/nginx 兩個容器間仍一致) ## 3. docker-compose.yml 調整(production) -- [ ] 3.1 [後端] `docker-compose.yml`:移除 `app`、`nginx`、`scheduler`、`queue-worker`、`reverb` 五個服務的 `./:/var/www` volume 定義 -- [ ] 3.2 [後端] `docker-compose.yml`:`nginx` 服務的 `image: nginx:alpine` 改為 `build: {context: ., dockerfile: Dockerfile, target: web}`,並將 `image:` 欄位改為本地命名(如 `cfdive-nginx-web`,與既有 Vue/Vite 前端的 `cfdive-frontend` 區分) -- [ ] 3.3 [後端] `docker-compose.yml`:新增具名 volume `storage-app-public`(定名,見 design.md Decision 3),掛載至 `app` 與 `nginx` 服務容器內的 **`/var/www/storage/app/public`**(絕對路徑),並在底部 `volumes:` 區塊註冊 -- [ ] 3.4 [後端] `docker-compose.yml`:**只在** `app`、`scheduler`、`queue-worker`、`reverb` **四個** Laravel runtime 服務加上 YAML list 格式的 `env_file:`: +- [x] 3.1 [後端] `docker-compose.yml`:移除 `app`、`nginx`、`scheduler`、`queue-worker`、`reverb` 五個服務的 `./:/var/www` volume 定義 +- [x] 3.2 [後端] `docker-compose.yml`:`nginx` 服務的 `image: nginx:alpine` 改為 `build: {context: ., dockerfile: Dockerfile, target: web}`,並將 `image:` 欄位改為本地命名(如 `cfdive-nginx-web`,與既有 Vue/Vite 前端的 `cfdive-frontend` 區分);**同時 `app` 服務的 `build:` 也必須明確加上 `target: app`**(實作驗證時發現:省略會導致 Docker 預設建到最後一個 stage,即 nginx,讓 `cfdive-platform` 實際變成 nginx image) +- [x] 3.3 [後端] `docker-compose.yml`:新增具名 volume `storage-app-public`(定名,見 design.md Decision 3),掛載至 `app` 與 `nginx` 服務容器內的 **`/var/www/storage/app/public`**(絕對路徑),並在底部 `volumes:` 區塊註冊 +- [x] 3.4 [後端] `docker-compose.yml`:**只在** `app`、`scheduler`、`queue-worker`、`reverb` **四個** Laravel runtime 服務加上 YAML list 格式的 `env_file:`: ```yaml env_file: - .env @@ -28,38 +28,44 @@ ## 4. 本機開發環境 override -- [ ] 4.1 [後端] 更新既有已版控的 `compose.override.yml`(`git ls-files` 確認已追蹤,非未版控檔案):為 `app`、`nginx`、`scheduler`、`queue-worker`、`reverb` 補上 `./:/var/www` bind mount,維持本機開發改 code 立即生效;不需另建 `.example` 檔案,此檔案本身即為所有開發者共用的標準範本 +- [x] 4.1 [後端] 更新既有已版控的 `compose.override.yml`(`git ls-files` 確認已追蹤,非未版控檔案):為 `app`、`nginx`、`scheduler`、`queue-worker`、`reverb` 補上 `./:/var/www` bind mount,維持本機開發改 code 立即生效;不需另建 `.example` 檔案,此檔案本身即為所有開發者共用的標準範本 ## 5. entrypoint 職責調整(只保留啟動必要工作,migration/cache/swagger/storage:link 全部移除) -- [ ] 5.1 [後端] `docker/php/docker-entrypoint.sh`:移除「`.env` 不存在則複製 `.env.example` + `key:generate`」區塊 -- [ ] 5.2 [後端] `docker/php/docker-entrypoint.sh`:移除「依 `composer.lock` 內容比對決定是否重新 `composer install`」區塊 -- [ ] 5.3 [後端] `docker/php/docker-entrypoint.sh`:移除強制改寫 `DB_HOST=db` 的 `preg_replace` 邏輯(改由 `.env`/compose `environment:` 直接提供正確值) -- [ ] 5.4 [後端] `docker/php/docker-entrypoint.sh`:移除等待 MySQL 的背景迴圈,以及該迴圈之後的 migration、`config:clear`/`cache:clear`/`route:clear`/`view:clear`、`l5-swagger:generate` 整段背景執行邏輯(責任移交 deploy.yml,見 group 6) -- [ ] 5.5 [後端] `docker/php/docker-entrypoint.sh`:移除 `storage:link` 執行(symlink 已於 1.5 在 image build 階段建立,執行期不需要) -- [ ] 5.6 [後端] `docker/php/docker-entrypoint.sh`:確認保留目錄與權限設定(`storage`、`bootstrap/cache`)作為 entrypoint 最終僅存的啟動必要工作 +- [x] 5.1 [後端] `docker/php/docker-entrypoint.sh`:移除「`.env` 不存在則複製 `.env.example` + `key:generate`」區塊 +- [x] 5.2 [後端] `docker/php/docker-entrypoint.sh`:移除「依 `composer.lock` 內容比對決定是否重新 `composer install`」區塊 +- [x] 5.3 [後端] `docker/php/docker-entrypoint.sh`:移除強制改寫 `DB_HOST=db` 的 `preg_replace` 邏輯(改由 `.env`/compose `environment:` 直接提供正確值) +- [x] 5.4 [後端] `docker/php/docker-entrypoint.sh`:移除等待 MySQL 的背景迴圈,以及該迴圈之後的 migration、`config:clear`/`cache:clear`/`route:clear`/`view:clear`、`l5-swagger:generate` 整段背景執行邏輯(責任移交 deploy.yml,見 group 6) +- [x] 5.5 [後端] `docker/php/docker-entrypoint.sh`:移除 `storage:link` 執行(symlink 已於 1.5 在 image build 階段建立,執行期不需要) +- [x] 5.6 [後端] `docker/php/docker-entrypoint.sh`:確認保留目錄與權限設定(`storage`、`bootstrap/cache`)作為 entrypoint 最終僅存的啟動必要工作 ## 6. 部署流程(.gitea/workflows/deploy.yml) -- [ ] 6.1 [後端] `deploy.yml`:`git reset --hard origin/master` 之後新增 `docker compose build` step -- [ ] 6.2 [後端] `deploy.yml`:把原本「Install Composer dependencies」step 移除(已在 image build 階段完成) -- [ ] 6.3 [後端] `deploy.yml`:新增 `docker compose up -d --remove-orphans` step,並加入等待 `app` container healthy 的檢查(如輪詢 `docker compose ps --format json` 或既有 healthcheck 狀態)後才繼續 -- [ ] 6.4 [後端] `deploy.yml`:確認 migration、`config:cache`/`route:cache`/`view:cache`/`event:cache`、`l5-swagger:generate` 幾個 step 移到 `app` healthy 之後執行 -- [ ] 6.5 [後端] `deploy.yml`:移除原本獨立的「Restart queue worker」step(`docker compose up -d` 已會因 image 變更重建該 container) +- [x] 6.1 [後端] `deploy.yml`:`git reset --hard origin/master` 之後新增 `docker compose build` step +- [x] 6.2 [後端] `deploy.yml`:把原本「Install Composer dependencies」step 移除(已在 image build 階段完成) +- [x] 6.3 [後端] `deploy.yml`:新增 `docker compose up -d --remove-orphans` step,並加入等待 `app` container healthy 的檢查(如輪詢 `docker compose ps --format json` 或既有 healthcheck 狀態)後才繼續 +- [x] 6.4 [後端] `deploy.yml`:確認 migration、`config:cache`/`route:cache`/`view:cache`/`event:cache`、`l5-swagger:generate` 幾個 step 移到 `app` healthy 之後執行 +- [x] 6.5 [後端] `deploy.yml`:移除原本獨立的「Restart queue worker」step(`docker compose up -d` 已會因 image 變更重建該 container) ## 7. 驗證 -- [ ] 7.1 [整合測試] 本機執行 `docker compose build && docker compose up -d`(含 override),確認五個服務容器全部啟動、`app` healthcheck(`nc -z 127.0.0.1 9000`)通過 -- [ ] 7.2 [整合測試] 本機容器內執行 `php artisan test`,確認既有測試套件全綠(比對變更前的 baseline 通過數) -- [ ] 7.3 [整合測試] 驗證本機開發體驗:override 生效時修改一個 PHP 檔案,確認容器內立即反映變更(不需 rebuild) -- [ ] 7.4 [整合測試] 驗證 production 模式:`docker compose -f docker-compose.yml up -d`(不含 override)啟動後,暫時搬移或改名主機端專案目錄,確認容器仍正常回應 API 請求 -- [ ] 7.5 [整合測試] 驗證上傳檔案持久性:上傳一張課程圖片,執行 `docker compose build && docker compose up -d` 重建 image,確認該圖片透過 nginx 仍可正常存取 -- [ ] 7.6 [整合測試] 驗證 `.env` 熱更新:修改 `.env` 內一個非敏感值,`docker compose up -d`(不 build),確認容器內 `php artisan tinker` 讀到新值 -- [ ] 7.7 [整合測試] 在 Gitea Actions 上以手動觸發或測試分支驗證完整 `deploy.yml` 新流程跑得通,確認 build 失敗時舊 container 是否仍維持運作(design.md 已知風險項) -- [ ] 7.8 [整合測試] VPS 正式部署後,人工驗證預約、聊天室、課程圖片上傳、教練證照上傳等現有功能正常,並確認 `scheduler`/`queue-worker`/`reverb` 皆以新 image 啟動(`docker compose ps` 確認 image ID 一致) -- [ ] 7.9 [整合測試] 確認 entrypoint/deploy 職責切分無重疊:`docker compose up -d` 啟動 `app`/`scheduler`/`queue-worker` 三個共用 entrypoint 的服務時,觀察其 log 不應出現 migration 或 `l5-swagger:generate` 執行紀錄,這些只應出現在 `deploy.yml` 執行紀錄裡 -- [ ] 7.10 [整合測試] 驗證 nginx 靜態資源獨立於 app:`docker compose build` 後單獨檢查 `nginx` container 內 `/var/www/public` 存在且可直接存取(如 `docker compose exec nginx ls /var/www/public`),停掉 `app` container 後 nginx 仍能透過 HTTP 直接回應 `public/` 下的靜態檔案(如 `favicon.ico`),確認不依賴 `app` container 是否存在或執行中 -- [ ] 7.11 [整合測試] 驗證 nginx 的 public/storage 符號連結獨立建立:`docker compose exec nginx ls -la /var/www/public/storage` 確認符號連結存在且指向 `../storage/app/public`,比對 `docker compose exec app ls -la /var/www/public/storage` 確認兩個容器**各自獨立**擁有此符號連結(非透過同一個共享狀態) -- [ ] 7.12 [整合測試] 驗證 nginx 透過 public/storage 正確服務已上傳檔案:先透過應用上傳一張課程圖片,直接對 nginx 發出該檔案的 HTTP 請求(`/storage/{path}`),確認回應內容正確,且此請求全程未經過 `app` container(php-fpm 只處理 `.php` 副檔名請求,見 `docker/nginx/conf.d/app.conf` 的 `location ~ \.php$`) -- [ ] 7.13 [整合測試] 驗證 composer 自動發現正確:`docker compose exec app php artisan route:list` 或任一依賴自動發現的第三方套件功能(如 l5-swagger)正常運作,確認 `package:discover` 於 build 階段正確執行 -- [ ] 7.14 [整合測試] 驗證 nginx 容器不具備 `.env` 內容:`docker compose exec nginx env | grep -i "DB_PASSWORD\|APP_KEY"` 應無任何輸出,確認 `nginx` 服務未設定 `env_file:` 生效 +- [x] 7.1 [整合測試] 本機執行 `docker compose build && docker compose up -d`(含 override),確認五個服務容器全部啟動、`app` healthcheck(`nc -z 127.0.0.1 9000`)通過 +- [x] 7.2 [整合測試] 本機容器內執行 `php artisan test`,確認既有測試套件全綠(比對變更前的 baseline 通過數)——239 passed/578 assertions(dev override 下 vendor 含 dev 依賴);發現並修復 `env_file:` 導致 `.env` 全部變數(非僅 DB_*)洩漏進容器成真實環境變數、覆蓋 `phpunit.xml` 測試設定值的問題,詳見下方「實作期間發現並修復」 +- [x] 7.3 [整合測試] 驗證本機開發體驗:override 生效時修改一個 PHP 檔案,確認容器內立即反映變更(不需 rebuild)——修改 `tests/bootstrap.php`/`phpunit.xml` 後未 rebuild 即生效,驗證通過 +- [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.9 [整合測試] 確認 entrypoint/deploy 職責切分無重疊:`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 內 +- [x] 7.12 [整合測試] 驗證 nginx 透過 public/storage 正確服務已上傳檔案:停掉 `app` container 後直接對 nginx 發出 `/storage/test2/file.txt` 請求,回應內容正確(200),確認全程未經過 `app` container +- [x] 7.13 [整合測試] 驗證 composer 自動發現正確:`docker compose exec app php artisan route:list` 正常回傳完整路由清單,`l5-swagger:generate` 正常執行,確認 `package:discover` 於 build 階段正確執行 +- [x] 7.14 [整合測試] 驗證 nginx 容器不具備 `.env` 內容:`docker compose exec nginx env | grep -i "DB_PASSWORD\|APP_KEY"` 無任何輸出,確認 `nginx` 服務未設定 `env_file:` 生效 + +### 實作期間發現並修復(2026-08-04,不在原規劃內) + +- **`docker-compose.yml` 的 `app` 服務缺少 `target: app`**:`Dockerfile` 改 multi-stage 後,`build:` 若未指定 `target:` 會預設建到最後一個 stage(`web`/nginx),導致 `cfdive-platform` image 實際上是 nginx 而非 php-fpm(`docker image inspect` 驗證 entrypoint 為 `nginx -g daemon off;`)。已在 `app` 服務的 `build:` 補上 `target: app` 並重新驗證兩個 image 的 entrypoint 分別正確。 +- **`.dockerignore` 需排除 `public/storage`**:本機既有的 `public/storage` 符號連結(先前用 `php artisan storage:link` 建立,絕對路徑指向 `/var/www/storage/app/public`)在 Windows 上會讓 Docker build context 傳輸失敗(`archive/tar: unknown file mode`)。已加入 `.dockerignore`。 +- **`env_file:` 使 `.env` 全部變數成為真實環境變數,覆蓋測試隔離**:`tests/bootstrap.php`/`phpunit.xml` 先前只强制覆蓋 `DB_CONNECTION`/`DB_DATABASE`(因為之前只有這兩個透過 compose `environment:` 洩漏),改用 `env_file: .env` 後 `.env` 的 `APP_ENV=local`/`CACHE_STORE=redis`/`SESSION_DRIVER=database`/`QUEUE_CONNECTION=database`/`MAIL_MAILER=smtp`/`BCRYPT_ROUNDS=12` 等全部變成真實環境變數,覆蓋 `phpunit.xml` 的 testing 值,導致 9 個測試失敗(快取污染、外寄信、500 錯誤等)。已擴大 `tests/bootstrap.php` 強制覆蓋清單涵蓋 `phpunit.xml` 宣告的全部 11 個變數,並在 `phpunit.xml` 對應加上 `force="true"` 自我說明。修復後 239 tests 全綠。 diff --git a/phpunit.xml b/phpunit.xml index c258bf1..2d42ec8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,19 +18,21 @@ - - - - - + + + + + - - - - - + + + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 09e35ac..50dcc16 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,10 +1,27 @@ 只覆蓋 $_ENV / putenv,而 Laravel env() 透過 -// phpdotenv 優先讀 $_SERVER——導致容器內跑測試時 RefreshDatabase 直接清空 -// 開發用 MySQL(2026-06-12 實際發生三次)。必須在 bootstrap 階段三者同步強制。 -foreach (['DB_CONNECTION' => 'sqlite', 'DB_DATABASE' => ':memory:'] as $key => $value) { +// docker-compose.yml 改用 env_file: 注入 .env 後,.env 內所有變數(不只 DB_*) +// 都變成真實環境變數(存在於 $_SERVER),PHPUnit 的 只覆蓋 +// $_ENV / putenv,而 Laravel env() 透過 phpdotenv 優先讀 $_SERVER——導致容器內 +// 跑測試時實際使用 .env 的 production-like 值(如 CACHE_STORE=redis、 +// SESSION_DRIVER=database、QUEUE_CONNECTION=database、MAIL_MAILER=smtp), +// 而非 phpunit.xml 宣告的 testing 值,曾造成 RefreshDatabase 清空開發用 MySQL +// (2026-06-12)、以及後續拿掉 bind mount 改用 env_file 後測試互相污染快取/ +// 對外寄信等問題(2026-08-04)。必須在 bootstrap 階段將 phpunit.xml 宣告的 +// 每一個 testing 值同步強制寫入 $_SERVER / $_ENV / putenv 三處。 +foreach ([ + 'APP_ENV' => 'testing', + 'APP_MAINTENANCE_DRIVER' => 'file', + 'BCRYPT_ROUNDS' => '4', + 'CACHE_STORE' => 'array', + 'DB_CONNECTION' => 'sqlite', + 'DB_DATABASE' => ':memory:', + 'MAIL_MAILER' => 'array', + 'PULSE_ENABLED' => 'false', + 'QUEUE_CONNECTION' => 'sync', + 'SESSION_DRIVER' => 'array', + 'TELESCOPE_ENABLED' => 'false', +] as $key => $value) { $_SERVER[$key] = $value; $_ENV[$key] = $value; putenv("{$key}={$value}");