算下来,我在macOS上折腾PHP本地开发环境的时间,少说也有一年多了。从最早直接在系统里装PHP、用脚本切版本,到后来换过几个集成环境工具,最后才彻底安定在Docker上。如果你也想在macOS上搭一套干净的Nginx + PHP环境,或者刚接了个PHP项目想在本地跑起来,这篇文章就是给你准备的。我会把从装Docker、到用Docker跑通一个PHP页面的全过程拆开讲,包括每个配置为什么这样写,以及我踩过的那些坑。
1. 为什么我最终把本地 PHP 环境交给 Docker 管
先说结论:Docker最大的价值不是"虚拟机"那种隔离,而是环境的一致性。以前在macOS上直接装PHP,最麻烦的是几个问题叠加在一起:
- 系统里同时存在多个PHP版本,切来切去,composer依赖可能用的还是旧版本解释器,排查起来很费劲。
- 装一个PHP扩展,经常要连带装一堆系统库,比如
libxml、libzip,一不小心把系统其他软件依赖的版本搞乱了。 - 一次macOS系统升级之后,本地Apache和PHP模块可能直接起不来,重新配一遍非常浪费时间。
- 最关键的,你在这台电脑上折腾好的环境,换一台电脑或者给同事跑,又得从头来一遍。
Docker把这些痛点全部绕过去了:PHP版本、Nginx版本、扩展、配置,全部写进文件里。整个环境可以提交到代码仓库,任何人克隆之后,执行一条命令就能起来一模一样的运行环境。
这个取舍用一个对比表看得更清楚:
| 方案 | 环境隔离 | 多版本切换 | 团队复现 | 对系统影响 | 上手成本 |
|---|---|---|---|---|---|
| 系统直接安装 | 差 | 困难 | 难 | 大 | 低 |
| 通过包管理器切换 | 中 | 一般 | 较难 | 中 | 中 |
| 集成的本地环境工具 | 中 | 一般 | 一般 | 中 | 低 |
| Docker容器方案 | 好 | 好 | 好 | 小 | 中 |
我并不是说其他方式不能用,如果你只维护一个很老的PHP项目、也不想接触容器概念,传统方式当然够用。但如果你手上有多个项目,PHP版本要求还不一样,Docker这套方案几乎是一劳永逸的。
2. macOS 安装 Docker 之前先搞清楚的几件事
2.1 你的Mac芯片类型决定了安装选择
装Docker之前,先搞清楚自己电脑的芯片类型。既能帮助选择Docker Desktop的版本(其实现在官网一个包同时支持两种芯片,不用刻意选),也决定你之后拉镜像时要注意什么。
在"关于本机"里能看到芯片信息:
- Apple Silicon:比如M1、M2、M3系列
- Intel:非常老款的Mac机型
Docker Desktop for Mac现在对Apple Silicon支持得很好了。Intel Mac也能正常用,只是镜像运行效率上会差一些。不管哪种芯片,安装方式都差不多,只是Apple Silicon上有个额外的Rosetta设置,后面会细说。
2.2 安装的两种方式和验证
官方最推荐的方式是下载Docker Desktop的安装包,dmg文件拖进Applications就行。我个人的建议是直接用Homebrew安装,后面升级更新方便:
brew install --cask docker装完之后,打开Docker Desktop,第一次启动会要你接受服务条款,然后等待Docker引擎初始化。这一步可能受网络环境影响,不要急。
确认安装成功,打开终端执行:
docker --version docker compose version如果都能正常打印版本信息,说明Docker命令行已经就绪。再跑一个官方的测试容器验证引擎:
docker run hello-world看到类似"Hello from Docker!"的输出,说明整个Docker链路是通的。
2.3 Apple Silicon 上的两个特殊选项
Apple Silicon的Mac拿到手之后装Docker Desktop,我建议检查两个地方。
第一个是是否开启"Use Rosetta for x86_64/amd64 emulation on Apple Silicon"。这个选项在Docker Desktop的Settings -> General里。它允许你在没有原生arm64镜像的情况下,运行老的amd64镜像。日常开发建议打开,因为你早晚会遇到某个老项目只提供了x86_64镜像的情况。
第二个是拉镜像时留意平台标识。现在大部分主流镜像都发布多架构版本,Docker会按当前机器的架构自动选择,但某些第三方镜像只有amd64,这时候如果你没开Rosetta,可能直接启动失败。
判断一个镜像支不支持当前架构,可以用这个命令查看:
docker inspect --format '{{.Architecture}}' 镜像名输出arm64说明是原生架构,输出amd64说明是通过模拟运行的。
2.4 资源分配别盲目给满
Docker Desktop在Settings -> Resources里可以配置CPU、内存和磁盘。默认分配一般够用,但如果你经常跑多个容器,建议把内存调到4GB以上。
不过注意,不要给满。macOS本身需要内存做各种缓存和程序运行,全给Docker之后,整个系统会变得很卡。我自己的经验是,日常开发分配4~8GB之间比较平衡。如果你只跑Nginx和PHP-FPM这种轻量容器,4GB绰绰有余。
磁盘也是同理,Docker会占一块动态增长的虚拟磁盘,建议在设置里把镜像存储位置放到剩余空间比较大的分区,并且定期清理不用的镜像。
docker system prune这条命令会清理一些悬空镜像和停止的容器,我一般隔一两个星期执行一次。
3. Nginx 和 PHP-FPM 的协作机制决定了你的配置方式
这一节听起来有点理论,但如果你不理解Nginx和PHP-FPM是怎么配合的,后面的配置只能靠猜,出了错也不知道从哪排查。我尽量讲得直白。
3.1 一次PHP请求的完整链路
假设用户在浏览器里访问了一个带PHP页面的网址。整个过程是:
- 浏览器请求到达Nginx,Nginx监听在某个端口上。
- Nginx根据请求的URI找到对应的文件路径。
- 如果请求的是
.php结尾的文件,Nginx自己并不会解析PHP,它会把请求转交给PHP-FPM。 - PHP-FPM是一个常驻的PHP进程管理器,它收到请求后,让PHP解释器执行对应脚本,拿到输出结果。
- PHP-FPM把结果原路返回给Nginx。
- Nginx再把响应返回给浏览器。
你可以把Nginx当成公司的前台,把PHP-FPM当成后台专员。来访者到前台,如果找的是现成的资料册(静态文件),前台直接拿给人家;如果要办业务(执行动态脚本),前台就打电话叫后台专员来处理。电话线就是FastCGI协议,后台专员的工位号码就是端口9000。
3.2 为什么是两个容器而不是一个容器塞全部
很多第一次接触的人会问,为什么不用一个容器,把Nginx和PHP都装进去?理论上可以,官方也提供过这样的镜像,但我建议你用两个独立容器。
原因有三个:
- Nginx和PHP的更新节奏不一样,分开容器可以互不影响地升级。
- 以后你想把PHP-FPM单独扩容,或者Nginx加个缓存层,独立容器方便调整。
- 共享同一个代码目录即可,容器之间通过内部网络通信,结构上更清晰。
Docker本身鼓励"一个容器只做一件事",Nginx容器只管HTTP处理,PHP-FPM容器只管PHP执行,代码目录通过挂载卷共享。
3.3 compose网络与服务名解析
当你同时启动Nginx容器和PHP容器时,它们如果加入了同一个Compose网络,那么在这个网络内部,可以用docker-compose.yml里定义的服务名来互相访问。
比如Nginx容器里配置fastcgi_pass php:9000;,这里的php不是IP,而是Compose为PHP容器分配的内部DNS名称。Docker会把它解析成PHP容器的IP地址。
这也是踩坑高发地,很多人把php误写成localhost或者127.0.0.1,结果Nginx自己找不到PHP服务,返回502。你要记住,在容器网络里,访问对端容器要写服务名,而不是本机回环地址。
3.4 关于端口映射的直觉理解
Docker容器有自己的网络命名空间,容器里的80端口不是Mac上的80端口。要让Mac上的浏览器访问到Nginx容器的80端口,需要做端口映射:
ports: - "8080:80"这个语法的意思是,宿主机(Mac)的8080端口转发到容器的80端口。宿主机端口在前,容器端口在后。
为什么要用8080而不是80?因为macOS上80端口经常被系统自带的Apache或者其他服务占用。用8080这种高位端口,能避免大多数冲突。当然真要用80也可以,但要去关掉macOS自带服务,后面会讲。
4. 从零跑通 Nginx + PHP:配置文件逐个拆解
4.1 目录结构
我建议在一个项目目录里,把Docker相关的配置独立放好。下面是我惯用的结构:
php-nginx-demo/ ├── docker-compose.yml ├── nginx/ │ └── conf.d/ │ └── default.conf ├── php/ │ └── Dockerfile └── www/ └── index.phpwww目录就是代码目录,Nginx和PHP容器都把它挂载进去。以后你的PHP项目文件就放这里。
4.2 docker-compose.yml 逐块讲解
先给完整的文件:
services: nginx: image: nginx:stable-alpine container_name: nginx-server ports: - "8080:80" volumes: - ./www:/usr/share/nginx/html - ./nginx/conf.d:/etc/nginx/conf.d depends_on: - php networks: - app-net php: image: php:8.2-fpm-alpine container_name: php-fpm volumes: - ./www:/var/www/html networks: - app-net networks: app-net: driver: bridge关键点解释:
nginx服务用的镜像是nginx:stable-alpine。选择alpine是因为体积小,基础依赖少,启动快。stable表示跟随稳定版主线,不会突然出现大版本破坏性变化。
container_name是指定容器名,方便命令行操作。不指定的话,Docker会自动生成一个随机名字,比如php-nginx-demo-php-1,操作起来不方便。但注意,容器名在同一个Docker主机里是唯一的,如果你同时跑多个项目,不要都叫nginx-server。
volumes里的映射,第一行把Mac上的./www目录挂载到Nginx容器内的/usr/share/nginx/html。第二行把Mac上的./nginx/conf.d目录挂载到Nginx容器内的/etc/nginx/conf.d,这样我们写的站点配置文件会被Nginx自动加载。
php服务用的镜像是php:8.2-fpm-alpine。这里8.2是PHP大版本,fpm表示这个镜像以PHP-FPM模式运行,alpine表示基础系统是精简Linux。这个镜像自带PHP-FPM,默认监听9000端口。
depends_on保证Nginx容器会在php容器之后启动。虽然这个字段不保证PHP-FPM已经完全就绪,但在实际场景里基本够用,Nginx也不会因为依赖没起来就崩掉。
app-net是自定义的桥接网络。Compose会自动把我们这个项目里声明的服务加入这个网络,同时自动生成内部DNS解析。如果你不用自定义网络,Compose也会生成一个默认网络,但显式声明更清晰,以后想在这个网络上加MySQL容器也方便。
4.3 Nginx 站点配置文件 default.conf
nginx/conf.d/default.conf的内容:
server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name; include fastcgi_params; } }逐个看:
root /usr/share/nginx/html;这个路径是Nginx容器里的代码根目录,对应挂载进去的Mac目录./www。这里写的是Nginx容器内看到的路径。
index index.php index.html;当用户访问目录时,优先找index.php。
location /块里的try_files $uri $uri/ /index.php?$query_string;是一个很常用的设置:先尝试找真实文件,如果找不到,就把请求交给index.php处理。很多现代PHP框架都依赖这种URL重写方式。
location ~ \.php$是一个正则匹配:凡是URI以.php结尾的请求,都进入这个块处理。
fastcgi_pass php:9000;这一行指定PHP-FPM的地址。php是Compose网络里PHP容器的服务名,9000是PHP-FPM默认监听端口。
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;这一行非常关键。它告诉PHP-FPM:你要执行的脚本文件的绝对路径。注意,这里必须写PHP容器内能看到的路径,因为PHP-FPM收到请求后,是在自己的文件系统里找文件。$fastcgi_script_name是请求的URI,比如/index.php,拼出来的完整路径就是/var/www/html/index.php。而我们的PHP容器正好把./www挂载到了/var/www/html,所以文件是能匹配上的。
很多人栽在这里,把SCRIPT_FILENAME写成/usr/share/nginx/html$fastcgi_script_name,这个路径在PHP容器里不存在,于是PHP-FPM找不到文件,Nginx就会返回404或者白屏。
4.4 测试用的 PHP 文件
在www目录下新建一个index.php:
<?php phpinfo();这个文件非常简单,作用是输出当前PHP的配置信息。如果最终浏览器能显示一大页PHP信息,就说明从Nginx到PHP-FPM的整个链路都是通的。
4.5 启动和验证流程
在项目根目录执行:
docker compose up -d-d表示后台运行。等一下再执行:
docker compose ps应该能看到两个容器状态都是running。接着可以看日志,确认没有报错:
docker compose logs -f然后打开浏览器访问:
http://localhost:8080如果一切正常,你会看到phpinfo的页面,PHP版本是8.2.x,Server API显示为FPM/FastCGI。到这一步,你的macOS上已经有一套容器化的Nginx + PHP环境了。
如果你习惯命令行,也可以用curl验证:
curl -I http://localhost:8080返回的HTTP头里Server: nginx就对了。
4.6 修改配置的正确姿势
当你改了Nginx配置,或者改了docker-compose.yml,需要重载或者重建容器。
改Nginx的站点配置,最轻量的方式是:
docker compose exec nginx nginx -s reload这条命令让Nginx平滑重载配置,不需要重启容器。
改了docker-compose.yml里的端口、卷映射之类的,需要:
docker compose up -dCompose会发现配置变化,自动重建受影响的容器。
改了PHP的Dockerfile(如果后面你自定义了),要:
docker compose build docker compose up -d我建议把这些命令记一个小抄,平时用得特别频繁。
5. 我实测踩过的那几个坑和排查链路
5.1 80端口被占:AirPlay和系统Apache
我在最开始用默认的"80:80"映射时,启动Nginx容器一直失败,提示port is already allocated。
在macOS上,最常见的占用者是:
- 系统自带的Apache,默认占用80端口。
- AirPlay接收器,在某些系统版本上占用5000端口,在新版本上还占用7000端口。虽然它占的不是80,但如果你拼命想用80,可能要把本机的Web共享关掉。
排查端口占用,用这个命令:
lsof -i :80就能看到是哪个进程在监听80端口。最省事的方式就是绕开80,用8080或者其他端口。如果你确实希望用80端口对外访问(比如有个项目配置里写死了回调地址),那要把占用80的进程清掉,但这会增加不稳定性,我反而不建议。
5.2 502 Bad Gateway 的完整排查链路
502是Nginx网关错误,本质是Nginx通过FastCGI访问PHP-FPM的时候失败了。遇到它别慌,按下面的顺序排查:
第一步,确认PHP容器在运行:
docker compose ps如果php容器不在列表里,或者状态是Exit,去查它的日志:
docker compose logs php第二步,确认PHP容器里的9000端口在监听。进入容器:
docker compose exec php ps aux看到类似php-fpm: master process就正常。再确认监听端口:
docker compose exec php sh -c "netstat -tlnp 2>/dev/null || ss -tlnp"如果没有netstat也没有ss,可能是精简镜像,那就看配置里listen指令,PHP官方fpm镜像默认监听9000。
第三步,确认Nginx容器能访问到php容器。我习惯直接进Nginx容器里用curl试探:
docker compose exec nginx curl php:9000正常情况会很快返回一个空响应或者报一个协议错误,但不会报域名解析失败。如果报Could not resolve host,说明网络里解析不到php这个服务名,这时候要检查Nginx容器和PHP容器是不是在一个Compose网络里。
第四步,检查Nginx的fastcgi_pass写的是不是php:9000。在Windows或者本机思维的影响下,特别容易写成127.0.0.1:9000,这在容器网络里是错的。容器内部网络里,127.0.0.1只代表它自己,Nginx容器里不可能有PHP-FPM进程。
整个排查链路里,90%的问题是出在fastcgi_pass地址错误或者容器没起来。
5.3 SCRIPT_FILENAME 写错导致的 404 和白屏
这个问题比502更隐晦。环境都起来了,访问index.php却返回404,看到Nginx错误日志里写Primary script unknown,十有八九是SCRIPT_FILENAME这个参数写错了。
原因前面讲过:Nginx容器和PHP容器对同一份代码目录的挂载路径不一样。Nginx容器里看到的是/usr/share/nginx/html/index.php,PHP容器里看到的是/var/www/html/index.php。FastCGI把请求转给PHP-FPM时,PHP-FPM只会按SCRIPT_FILENAME参数去找文件,这个参数必须用PHP容器视角的路径。
所以:
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;不能写成:
fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html$fastcgi_script_name;这两个路径虽然指向的是同一份文件(因为挂载的是同一个宿主机目录),但一个在PHP容器里不存在,另一个存在。搞混了,PHP-FPM就找不到目标文件。
如果你只是想在Nginx配置里快速查看容器内的真实路径,可以用:
docker compose exec php sh -c "ls -l /var/www/html"确认你的代码文件已经在PHP容器里了。
5.4 挂载目录的权限问题
在macOS上,Docker挂载目录后,容器内看到的文件属主通常是Mac当前用户的UID映射。而PHP-FPM默认以www-data用户运行,它的UID在容器里通常不是501。
表现症状是:PHP代码读自己的文件没问题(因为文件有全局读权限),但写文件时失败。比如生成日志、上传文件、创建缓存目录都会报Permission denied。
临时解决方案:把目标目录的权限放宽。在Mac终端里执行:
chmod -R 0777 ./www/storage这是开发环境常见的偷懒做法,但不适合生产。我建议在Dockerfile里把PHP-FPM的运行用户改成与Mac当前用户相同,但这一步涉及到用户体系映射,对新手来说有点复杂,如果你目前只是把项目跑起来看效果,先把目录权限放宽就够了。最干净的长期方案是用容器内的user配置指定当前UID,但要注意挂载进去的文件写入不会再用www-data的权限,也可能会引入其他问题,属于进阶玩法。
5.5 镜像平台不匹配的问题
Apple Silicon上跑老项目镜像,有时启动报错,或者容器运行中莫名崩溃,先考虑是不是架构不匹配。解决方案:
优先选用官方或者多架构镜像。如果在Compose里想明确指定平台,可以这样:
services: php: image: php:8.2-fpm-alpine platform: linux/arm64强制让Docker选择arm64版本。如果某个老镜像只发布过amd64架构,你又确实要用,要靠之前提到的Rosetta模拟支持。开启后,Docker会通过QEMU转译运行amd64容器,但性能会打折扣。
6. 进阶:把 PHP 扩展固化到镜像里
6.1 为什么基础镜像缺扩展
php:8.2-fpm-alpine为了控制体积,只带了PHP核心和默认编译的插件。实际开发中我们要用的扩展,比如pdo_mysql(连MySQL用)、mysqli、opcache,都得自己装。
直接在容器里docker-php-ext-install也能装,但容器一删就没了。正确做法是写Dockerfile,把扩展固化到镜像里。
6.2 编写自定义 Dockerfile
在php目录下新建Dockerfile:
FROM php:8.2-fpm-alpine RUN apk add --no-cache --virtual .build-deps \ $PHPIZE_DEPS \ libzip-dev \ && docker-php-ext-install pdo_mysql mysqli opcache zip \ && apk del .build-deps \ && docker-php-ext-enable pdo_mysql mysqli opcache zip WORKDIR /var/www/htmlapk add是在Alpine里装依赖包,--virtual .build-deps的意思是把这些包归到一个虚拟组里,装完扩展后可以一次性删除,减小镜像体积。$PHPIZE_DEPS是官方镜像里预设的编译工具集,装了它才能用docker-php-ext-install做编译。
docker-php-ext-install是PHP官方镜像提供的扩展安装脚本,它会自动下载对应版本的扩展源码并编译安装,比手动pecl install方便得多。docker-php-ext-enable则是确保这些扩展在php.ini里被启用。
然后修改docker-compose.yml,把php服务的image改成build:
services: php: build: ./php container_name: php-fpm volumes: - ./www:/var/www/html networks: - app-net重新构建并启动:
docker compose build docker compose up -d验证扩展是否生效,可以访问之前那个phpinfo()页面,搜mysqli或者pdo_mysql,能看到对应段落说明扩展已经加载。
6.3 以后想加 Redis 扩展怎么办
pdo_mysql这类扩展用docker-php-ext-install就行,但Redis这类第三方扩展需要通过PECL安装。在官方镜像里可以这样写:
RUN pecl install redis && docker-php-ext-enable redis不过这种方式需要提前确认当前PHP版本对Redis扩展的兼容性。为了避免踩编译环境的坑,社区有一个叫install-php-extensions的脚本,用起来很省心,能让扩展安装过程自动化很多。这里不深入展开,但值得知道有这条捷径。
7. 实际用下来的体会和几个小建议
这套环境我用了一年多,给我最大的感受是"不折腾"了。换了台电脑,把项目代码和这个Compose目录拉下来,一条docker compose up -d,环境就回来了。加了同事,也不用在群里发清一色的"在我这跑得好好的"。
我有几条个人建议:
第一,把docker-compose.yml和nginx/conf.d、php/Dockerfile都纳入Git管理。这样配置的任何修改都有记录,哪天改出问题可以回滚。项目代码放在www目录,但不要把容器产生的日志和缓存目录也提交进去,那不属于源码。
第二,端口映射尽量用高位端口。不同项目之间避免端口冲突,我给项目分配端口时用过8080、8081、8082这种递增规则,省了不少麻烦。
第三,养成看日志的习惯。遇到问题第一反应不应该是"重启大法",而是docker compose logs -f看输出。很多错误其实日志里写得明明白白,直接定位比盲猜快得多。
第四,这套方案适合本地开发和测试,不要直接照搬上生产。生产环境需要做更多加固:不用root运行进程、限制容器资源、日志集中收集、镜像体积控制,这些都要根据实际场景再加工。
顺手分享一个小技巧,把常用的Compose命令做成别名:
alias dc='docker compose' alias dcu='docker compose up -d' alias dcl='docker compose logs -f' alias dcd='docker compose down'放在~/.zshrc里,重载一下,日常开发真的方便很多。
最后,这套基础架构只是起点。你可以很自然地在Compose文件里继续加MySQL容器、Redis容器,把Nginx的fastcgi_pass、PHP的连接参数指向新服务名,整体工程就从一个静态页面演示,长成一个完整的本地微服务开发环境了。我目前的日常开发基本都跑在这套模式上,稳定性和可维护性都让我很满意。