Docker 化一個 10 年的 PHP 專案:踩過的坑
這篇文章的系統背景是多租戶 PHP 7.4 企業系統(AppSystem)。如果你想了解多租戶架構的細節,可以先閱讀: 📖 多租戶架構生存指南:一個資料庫一個機構的愛與愁
🎯 前言
「我機器可以跑」——這句話在我們團隊聽了好幾年。
某次新成員加入,花了將近半天才把開發環境建起來。過程中踩了 PHP 擴充套件版本問題、MySQL sql_mode 差異、php.ini 設定不一致等問題。每次都要翻出一份口耳相傳的「環境建置筆記」,還不一定更新。
那次之後我們決定:是時候把這個跑了 10 年的 PHP 專案 Docker 化了。
這篇文章記錄整個過程踩過的坑。不是教學文,是踩坑實錄。
📋 遷移前的系統現狀
在 Docker 化之前,AppSystem 的開發環境長這樣:
- Windows 開發者:XAMPP,Apache + PHP 7.4 + MySQL 5.7
- Mac 開發者:MAMP 或手動安裝,設定因人而異
- 共用的
.env設定:靠口頭傳遞,沒有統一管理 - 資料庫初始化腳本:存在某個人的硬碟裡,或某個 Slack 訊息中
觸發 Docker 化的最後一根稻草:線上發生一個神秘 bug,某個 SQL query 在開發環境跑正常,上到正式就錯誤。追查後發現是 MySQL sql_mode 差異——開發環境的 XAMPP 用的是空字串,正式環境用的是另一組 mode,導致 GROUP BY 的行為不一樣。
這件事讓我們意識到:環境不一致是隱藏的技術債。
🏗️ 容器架構設計
Nginx + PHP-FPM 分離,而非 php:7.4-apache
我們選擇 Nginx + PHP-FPM 架構,而不是官方的 php:7.4-apache:
| 方案 | 優點 | 缺點 |
|---|---|---|
php:7.4-apache | 簡單,一個容器搞定 | 正式環境用 Nginx,開發/正式不一致 |
| Nginx + PHP-FPM | 與正式環境一致,可獨立調整 | 設定稍複雜 |
既然正式環境已經是 Nginx,開發環境也用 Nginx 才能讓問題在本地就被發現。
Volume 策略
程式碼(src/) → bind mount(直接掛入,hot reload)
vendor/ → named volume(不掛入容器,避免效能問題)
MySQL 資料 → named volume(持久化)
上傳檔案 → named volume(持久化)
關鍵決策:vendor/ 不要從主機掛入容器。原因見後面的坑四。
❌ 坑一:PHP 擴充套件版本地獄
這是花最多時間的部分。AppSystem 需要的 PHP 擴充套件:
# 這些是必要的,但安裝方式各有不同
gd # 圖片處理
zip # ZIP 壓縮
intl # 國際化
bcmath # 高精度計算
pdo_mysql # MySQL PDO
mbstring # 多位元組字串
問題出在 gd 的依賴:
# ❌ 這樣安裝 gd 會缺少 JPEG 支援
RUN docker-php-ext-install gd
# ✅ 必須先安裝系統依賴
RUN apt-get update && apt-get install -y \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libzip-dev \
libicu-dev \
&& docker-php-ext-configure gd \
--with-freetype \
--with-jpeg \
&& docker-php-ext-install -j$(nproc) \
gd zip intl bcmath pdo_mysql mbstring
另一個踩到的坑是 mcrypt——這個擴充套件在 PHP 7.2 之後已棄用並移除,但 AppSystem 的某些舊程式碼還在用。
解決方案是透過 PECL 安裝舊版:
# 安裝已棄用的 mcrypt(必要之惡)
RUN apt-get install -y libmcrypt-dev \
&& pecl install mcrypt-1.0.4 \
&& docker-php-ext-enable mcrypt
❌ 坑二:MySQL 5.7 容器設定
sql_mode 必須強制設定
這就是觸發 Docker 化的那個問題。必須明確設定 sql_mode:
# docker-compose.yml
services:
db:
image: mysql:5.7
command: >
--sql_mode=""
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--innodb_strict_mode=OFF
注意 --sql_mode="" 要設成空字串,而不是預設值。這樣才和正式環境一致。
初始化多個資料庫
AppSystem 在本地開發只需要建立幾個測試用的機構資料庫(不需要全部 20+)。MySQL 容器支援在 /docker-entrypoint-initdb.d/ 放置 .sql 初始化腳本:
# docker/mysql/init/
├── 00_create_databases.sql # 建立資料庫
├── 01_system_main.sql # 系統主庫結構
├── 02_demo_org_1.sql # 測試機構 1 結構
└── 03_demo_org_2.sql # 測試機構 2 結構(只建兩個就夠了)
-- 00_create_databases.sql
CREATE DATABASE IF NOT EXISTS `system_main`
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS `demo-org-1`
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS `demo-org-2`
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 建立應用程式使用者
CREATE USER IF NOT EXISTS 'dbuser'@'%' IDENTIFIED BY 'dbpass';
GRANT ALL PRIVILEGES ON `demo-%`.* TO 'dbuser'@'%';
GRANT ALL PRIVILEGES ON `system_main`.* TO 'dbuser'@'%';
FLUSH PRIVILEGES;
❌ 坑三:XDebug 跨平台惡夢
這是整個 Docker 化過程中最讓人頭痛的部分。
XDebug 需要知道「要連回到哪個 IP 才能到達開發者的 IDE」,但在不同平台上,容器要連回主機的方式不同:
| 平台 | 主機 IP | 說明 |
|---|---|---|
| macOS | host.docker.internal | Docker Desktop 自動提供 |
| Windows + Docker Desktop | host.docker.internal | 同上 |
| Linux | 172.17.0.1 | Docker 橋接網路的主機端 IP |
| Linux (WSL2) | 需要查詢 /etc/resolv.conf | 每次重開機可能改變 |
我們用環境變數解決:
; docker/php/xdebug.ini
[xdebug]
xdebug.mode = debug
xdebug.start_with_request = yes
xdebug.client_host = ${XDEBUG_CLIENT_HOST}
xdebug.client_port = 9003
xdebug.log_level = 0
然後在 .env 中讓每個開發者自行設定:
# .env(每個開發者本地設定,不進 git)
# macOS / Windows
XDEBUG_CLIENT_HOST=host.docker.internal
# Linux
# XDEBUG_CLIENT_HOST=172.17.0.1
# WSL2(需要手動查詢後填入)
# XDEBUG_CLIENT_HOST=172.xxx.xxx.xxx
.env.example 進 git,.env 加入 .gitignore。
❌ 坑四:Volume 掛載策略
macOS 的效能問題
在 macOS 上,Docker 的 bind mount 效能非常差。整個 vendor/ 目錄(幾千個 PHP 檔案)如果掛入容器,每次請求都要跨越 VM 邊界存取檔案,速度慢到難以接受。
解法:vendor/ 用 named volume,不要 bind mount:
services:
app:
volumes:
- ./src:/var/www/html/AppSystem # 程式碼 bind mount(hot reload)
- vendor_data:/var/www/html/AppSystem/vendor # vendor 用 named volume
volumes:
vendor_data:
這樣 vendor/ 只存在容器的 named volume 中,不跨越 VM 邊界,速度大幅提升。
副作用:在主機上看不到 vendor/ 目錄(IDE 的自動補全可能受影響)。解法是另外在主機跑一次 composer install,但讓 Docker 不掛入那個目錄。
不要把 .env 也掛進去
# ❌ 這樣主機的 .env 和容器看到的是同一份
volumes:
- ./.env:/var/www/html/AppSystem/.env
# ✅ 用 environment 或 env_file,更明確
env_file:
- .env
❌ 坑五:多租戶架構的特殊挑戰
多租戶架構在 Docker 化時有幾個特殊問題:
資料庫初始化腳本的順序問題
MySQL 容器的 docker-entrypoint-initdb.d/ 按檔名排序執行,要確保建立資料庫的腳本在建立結構的腳本之前跑:
00_create_databases.sql # 必須第一個
01_system_main.sql
02_demo_org_1.sql
03_demo_org_2.sql
測試環境只需要部分機構資料庫
正式環境有 20+ 個機構,但本地開發只需要 2-3 個測試機構。這個「子集合」的概念在原來的 XAMPP 環境中很自然(有幾個 dump 就 import 幾個),但在 Docker 中需要明確設計。
我們的做法是維護一份「開發用測試資料」的 SQL dump,只包含 demo-org-1 和 demo-org-2,進 git 管理。
🛠️ 最終的 docker-compose.yml 架構
# docker-compose.yml(精簡版)
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./src:/var/www/html/AppSystem:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
app:
build:
context: .
dockerfile: docker/php/Dockerfile
volumes:
- ./src:/var/www/html/AppSystem
- vendor_data:/var/www/html/AppSystem/vendor
env_file:
- .env
depends_on:
- db
db:
image: mysql:5.7
command: >
--sql_mode=""
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpass
volumes:
- mysql_data:/var/lib/mysql
- ./docker/mysql/init:/docker-entrypoint-initdb.d:ro
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
volumes:
mysql_data:
vendor_data:
四個服務:
- nginx:反向代理,處理靜態檔案
- app:PHP-FPM,執行 PHP
- db:MySQL 5.7,設定好
sql_mode - mailhog:攔截所有寄出的 email(測試用)
完整 Dockerfile
# docker/php/Dockerfile
FROM php:7.4-fpm
# 安裝系統依賴
RUN apt-get update && apt-get install -y \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libzip-dev \
libicu-dev \
libmcrypt-dev \
git \
unzip \
&& rm -rf /var/lib/apt/lists/*
# 安裝 PHP 擴充套件
RUN docker-php-ext-configure gd \
--with-freetype \
--with-jpeg \
&& docker-php-ext-install -j$(nproc) \
gd \
zip \
intl \
bcmath \
pdo_mysql \
mbstring \
opcache
# 安裝 mcrypt(已棄用但仍需要)
RUN pecl install mcrypt-1.0.4 \
&& docker-php-ext-enable mcrypt
# 安裝 XDebug
RUN pecl install xdebug-3.1.6 \
&& docker-php-ext-enable xdebug
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
# 安裝 Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# 安裝 vendor(cached in named volume)
COPY src/composer.json src/composer.lock /var/www/html/AppSystem/
RUN cd /var/www/html/AppSystem && composer install --no-dev --optimize-autoloader
💡 Docker 化後的實際效益
環境建置時間
| 指標 | Docker 化前 | Docker 化後 |
|---|---|---|
| 新成員環境建置 | 3-4 小時 | 15 分鐘 |
| 「我機器可以跑」問題 | 每個月幾次 | 幾乎消失 |
| MySQL sql_mode 差異 bug | 偶發 | 消失 |
CLAUDE.md 的受益
Docker 化後,CLAUDE.md 的環境說明可以更精確:
## 本地開發環境
使用 Docker Compose 啟動:`docker compose up -d`
服務說明:
- Nginx:http://localhost:80
- PHP:7.4-FPM(容器內)
- MySQL:5.7,port 3306,sql_mode=""
- MailHog(攔截 email):http://localhost:8025
進入 PHP 容器:`docker compose exec app bash`
這讓 Claude Code 在分析問題時,能準確理解環境設定,而不是猜測。
🎉 結語
Docker 化一個 10 年的 PHP 專案,踩的坑比預期多——但每一個坑都值得踩。
事後回頭看,最重要的三個決定是:
- Nginx + PHP-FPM 而非 php:apache:保持開發和正式環境一致
- vendor/ 用 named volume:解決 macOS 效能問題
- XDebug 用環境變數:解決跨平台問題
這三個決定省去了後來很多麻煩。如果你也在考慮 Docker 化 Legacy PHP 專案,希望這篇踩坑記錄能讓你少走一些彎路。
📎 相關文章: