我们团队上个月刚从 Homestead 迁移到 Docker Compose,因为一次 composer install 后生产环境直接 502。说白了就是本地跑得好好的,线上 PHP 版本不对。那周我们修了 3 天,损失了 2 个迭代日的工时,不算代码量,光是沟通成本就花了 4000 块人民币。

这件事让我铁了心把本地环境彻底容器化。这篇写的是最近 60 天里,我们用 Docker Compose 搭建 Laravel 10 开发环境的全过程,包括 18 个踩坑点里挑出的 4 个最狠的,以及最终沉淀下来的最佳实践。

为什么是 Docker Compose,而不是 Homestead 或 Laravel Sail

说实话,Homestead 用了两年,稳定性还行,但它是个 Vagrant 虚拟机,启动要 45 秒,内存占用 1.2 GB 起步。我们团队 8 个人,每人一台 MacBook Pro 16GB,跑两个项目就卡成狗。Laravel Sail 倒是轻,但它是封装好的 Docker 镜像,改动底层比较麻烦,自定义 PHP 扩展、切换 Redis 版本这类事,Sail 要改源码。

回头看,Docker Compose 最核心的优势是声明式。一个 docker-compose.yml 文件,把 PHP-FPM、Nginx、MySQL、Redis 四个服务的版本、配置、网络全部写死。新同事入职,克隆代码后 docker-compose up -d,5 分钟就能开始写代码。而 Homestead 的 homestead.yaml 得手动改端口映射、同步文件夹,错了也没人能立刻发现。

我们团队做过一次测试:让一个只写过 Laravel 但不熟 Docker 的同事,分别用 Homestead 和 Docker Compose 搭环境。Homestead 他花了 40 分钟,中途还因为 SSH key 权限问题卡了 20 分钟。Compose 他照着 README 里的命令,12 分钟全部跑通。数字摆在这,没有不换的理由。

踩坑实录:4 个真正让我血压上升的问题

坑 1:文件权限 N 次把我锁在门外

现象:docker-compose up -d 后,访问页面 500,查看日志是 file_put_contents(storage/logs/laravel.log): failed to open stream: Permission denied。

困惑:我在宿主机上明明给了 chmod -R 777 storage,为什么进容器内还是没权限?

解法:容器内 PHP-FPM 进程是 www-data 用户(UID 33),宿主机上你用的 UID 可能是 501(macOS)或 1000(Linux)。挂载卷后,文件权限跟随宿主机,但容器内进程以 www-data 运行,而宿主机文件 owner 是 501,www-data 无法写。最终我在 docker-compose.yml 里给 PHP-FPM 服务加了 user: "${UID}:${GID}",并在 .env 里定义 UID/GID。注意 Linux 上用 id -u 获取,macOS 上默认不是 1000,得手动写。

血泪教训:任何持久化挂载目录,都要显式匹配容器用户和宿主用户。我们后来统一把所有容器用户改为 1000:1000,宿主机也改成这个 UID。

坑 2:Composer 版本不一致,锁文件变废纸

现象:同事 A 本地 composer update 后,composer.lock 更新到了依赖 package 的某个 patch 版本,同事 B 拉代码后 composer install 报错 Your requirements could not be resolved.

困惑:明明我们都用同一个 PHP 镜像,为什么会出现依赖冲突?

解法:看 docker-compose.yml,我们两个 PHP-FPM 服务分别用了 php:8.2-fpm-alpine 和 php:8.2-fpm,Alpine 版本里 composer 是 2.2,非 Alpine 里是 2.6。Composer 2.2 和 2.6 在解析某些复杂依赖时行为不同,导致 lock 文件无法兼容。后来统一用 php:8.2-fpm(Debian base),并且在 Dockerfile 里 RUN composer install --no-dev --prefer-dist,但开发环境用挂载卷的 composer。最终我们固定了镜像 digest:php:8.2-fpm@sha256:xxxx,彻底锁死。

Day 1 我们只写了 php:8.2-fpm,第 2 周就被坑了。现在 CI 和本地都用同一个完整 digest,再没出过这种问题。

坑 3:MySQL 8 默认字符集导致中文乱码

现象:保存中文数据到数据库,查看时全是 ???,或者 Incorrect string value: '\xE6\x9B\xBE' for column 'name'.

困惑:Laravel 迁移里明明写了 ->charset('utf8mb4'),为什么还是乱码?

解法:MySQL 8 默认字符集是 utf8mb4_0900_ai_ci,但连接层设置不对。我的 docker-compose.yml 里 MySQL 容器没指定 command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci,导致数据库服务器创建时用了默认 latin1。之后哪怕表是 utf8mb4,连接字符集也会被协商成 latin1。解法是在 MySQL 服务的中加两个命令行参数,并在 Laravel 的 config/database.php 里加 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci',同时把 DATABASE_URL 里加上 ?charset=utf8mb4。这个修复花了我们半天。

话说回来,这问题在 Homestead 里从没出现过,因为 Homestead 的 MySQL 配置是预置好的。但 Compose 给了你自由,你就要担这个责任。

坑 4:本地热更新失效,改代码要重启容器

现象:修改了 Laravel 的 .env 或 composer.json,页面没变化,Nginx 返回旧缓存。

困惑:PHP-FPM 不是有 OPCache 吗?我明明在 php.ini 里开了 opcache.validate_timestamps=1 并设置了 revalidate_freq=0.

解法:问题出在挂载方式。我们用 Docker Desktop for Mac 的绑定挂载 ./:/var/www/html,但 Mac 的绑定挂载性能差,且偶尔文件事件通知不触发,OPCache 的 timestamp 检测不可靠。解法:改用 Docker Volume(命名卷),并使用 :delegated 挂载标志。另外,在 PHP-FPM 的 docker-entrypoint.sh 里显式 touch 文件或强制 OPCache 重置。最干净的方法是开发环境不开启 OPCache(opcache.enable=0),因为本地没有真正的性能压力,开了反而坑人。我们团队现在统一在 Dockerfile 里写了 RUN docker-php-ext-install opcache,但 php.ini 开发文件里 opcache.enable=0。

我强烈建议:开发环境永远别开 OPCache。节省的半秒性能远没有你踩坑的时间值钱。

最终 Docker Compose 配置:我们打磨了 18 天的产物

下面是我们项目 docker-compose.yml 的核心精简版,PHP-FPM 和 Nginx 服务,外加 MySQL 和 Redis。

services:
  php:
    image: php:8.2-fpm@sha256:3f6f7e9a
    container_name: laravel_php
    user: "1000:1000"
    working_dir: /var/www
    volumes:
      - ./:/var/www:delegated
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
      - XDEBUG_MODE=off
    command: php-fpm
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "8080:80"
    volumes:
      - ./:/var/www:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - php
  mysql:
    image: mysql:8.0.35
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    environment:
      MYSQL_DATABASE: laravel
      MYSQL_USER: dev
      MYSQL_PASSWORD: dev
      MYSQL_ROOT_PASSWORD: root
    volumes:
      - mysql_data:/var/lib/mysql
  redis:
    image: redis:7.2-alpine
    volumes:
      - redis_data:/data
volumes:
  mysql_data:
  redis_data:

Nginx 配置里要确保 fastcgi_pass php:9000,并且 index index.php。这个配置我们用了 30 天,没有再踩坑。

对比一下我们之前 Homestead 和切换后的效果:

项目 Homestead Docker Compose 变化
启动时间 45 秒 3.2 秒 -92.9%
内存占用 1.2 GB 380 MB -68.3%
新同事搭建时间 40 分钟 12 分钟 -70%
多人共享项目 手动同步,冲突多 单文件声明,无冲突 本质改善
PHP 版本切换 改配置重启,易出错 改 image tag 一键换 复杂度大幅降低

数据来自我们团队 4 月 10 日做的实测,8 人完全一致。

我们沉淀下来的 6 条最佳实践

  • 镜像固定 digest,不要用 mutable tag。php:8.2 会变,php:8.2@sha256:xxx 一辈子不变。
  • 容器内用户与宿主机 UID 对齐,统一用 1000:1000,避免权限地狱。
  • 开发环境关闭 OPCache,同时设置 :delegated 挂载,提升 Mac 性能。
  • 所有持久化数据放到命名卷,不绑宿主目录。MySQL 数据一旦绑定,跨系统迁移会炸。
  • 用 depends_on 配合 healthcheck,别裸依赖。比如 PHP 等待 MySQL 端口可用才启动。
  • .dockerignore 必须写,否则 vendor 和 storage 会被打包进镜像,构建慢 2 倍。

这几个点每个都是我们踩过坑才总结的。比如 healthcheck,之前没写,PHP 容器启动了但 MySQL 还在初始化,导致 Laravel 报 SQLSTATE[HY000] [2002] Connection refused,后来加了 healthcheck 和 condition: service_healthy 才解决。

哪些地方 Docker Compose 真的不如 Homestead

但我也要承认 Docker Compose 不如 Homestead 的地方,主要有三点。

第一,国内网络环境下拉取镜像和依赖速度慢。Docker Hub 经常抽风,我们团队在 3 月 25 日拉 php:8.2-fpm 花了 10 分钟,还失败两次。Homestead 的 Vagrant box 可以通过挂载国内镜像源加速,Docker 则要用 registry-mirrors 配置,而且不一定百百有效。对新手来说这是第一道门槛。

第二,PHP 扩展安装成本更高。Homestead 自带 pcntl, redis, xdebug 等 20 多个扩展,开箱即用。Docker Compose 得在 Dockerfile 里 docker-php-ext-install pdo_mysql redis,编译要 5 到 8 分钟。如果遇到需要编译扩展的,还得装 $PHPIZE_DEPS,环境变量一错就失败。Homestead 里 apt install php8.2-redis 三秒搞定。

第三,日志调试的复杂度上升。Homestead 的日志直接在 VM 里,用 tail -f 就能看。Docker Compose 要 docker logs 或者挂载到宿主机,而且 Nginx、PHP-FPM、MySQL 三套日志分别在不同容器,串联起来需要 grep 时间戳,麻烦。我们后来写了 docker-compose logs -f --tail=50 的 alias,但还是没有 Homestead 下的一体化感觉。

说这些不是劝退,而是想告诉你:Docker Compose 在可控性和性能上赢了,但学习成本和网络环境是硬伤。如果你的团队只有一两个人,且不需要频繁切换 PHP 版本,Homestead 依然可用。

最后一个提醒

如果你的项目还在用 Laravel 9 和 PHP 8.1,别急着升 8.2。我们 4 月初把 PHP 从 8.1 升到 8.2,结果 str_contains 行为差异导致 3 个测试失败,花了半天才查出来是 PHP 8.2 对 null 参数类型更严格了。Docker Compose 给了你任意切换版本的便利,但版本差异是你必须自己扛的。

还有一点,docker-compose v2 命令 docker compose 和 docker-compose 有细微差别,建议统一用 docker compose 避免 shell 环境问题。

现在,你的本地环境还是一团乱麻吗?