☰
macOS上用Docker搭建Nginx+PHP本地开发环境:配置与避坑指南
2026/10/21 12:23:41 网站建设 项目流程

算下来,我在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页面的网址。整个过程是:

  1. 浏览器请求到达Nginx,Nginx监听在某个端口上。
  2. Nginx根据请求的URI找到对应的文件路径。
  3. 如果请求的是.php结尾的文件,Nginx自己并不会解析PHP,它会把请求转交给PHP-FPM。
  4. PHP-FPM是一个常驻的PHP进程管理器,它收到请求后,让PHP解释器执行对应脚本,拿到输出结果。
  5. PHP-FPM把结果原路返回给Nginx。
  6. 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.php

www目录就是代码目录,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 -d

Compose会发现配置变化,自动重建受影响的容器。

改了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/html

apk 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的连接参数指向新服务名,整体工程就从一个静态页面演示,长成一个完整的本地微服务开发环境了。我目前的日常开发基本都跑在这套模式上,稳定性和可维护性都让我很满意。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询