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說明
macOShost.docker.internalDocker Desktop 自動提供
Windows + Docker Desktophost.docker.internal同上
Linux172.17.0.1Docker 橋接網路的主機端 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-1demo-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 專案,踩的坑比預期多——但每一個坑都值得踩。

事後回頭看,最重要的三個決定是:

  1. Nginx + PHP-FPM 而非 php:apache:保持開發和正式環境一致
  2. vendor/ 用 named volume:解決 macOS 效能問題
  3. XDebug 用環境變數:解決跨平台問題

這三個決定省去了後來很多麻煩。如果你也在考慮 Docker 化 Legacy PHP 專案,希望這篇踩坑記錄能讓你少走一些彎路。


📎 相關文章