我们团队上个月刚从 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 环境问题。
现在,你的本地环境还是一团乱麻吗?