☰
macOS上用Docker部署Nginx与PHP-FPM:docker-compose实战指南
2026/10/20 14:57:35 网站建设 项目流程

1. 环境准备:macOS安装Docker的正确打开方式

先说结论:在macOS上装Docker,最省心的方案就是用Docker Desktop。别纠结什么手动装binaries、用Homebrew装docker cli之类的高级操作,除非你要搞CICD流水线那套,否则Docker Desktop自带图形界面、自带k8s集群、自带资源监控面板,对日常开发部署来说,省下的时间足够你去处理Nginx和PHP那堆配置了。

我最早是在一台老款Intel芯片的MacBook上试的,内存16G,硬盘256G。装好之后发现最常见的问题反而不是Docker本身,而是硬盘空间和内存分配。Docker Desktop默认会给虚拟机分配2个CPU和2G内存,这个配置跑一个Nginx加一个PHP-FPM倒是够,但如果你同时还要跑其他容器,比如MySQL、Redis,那2G内存就明显吃紧。建议第一次安装完,直接打开设置界面,把内存调整到4G以上,CPU给到4核,镜像存储位置也检查一下,别放在系统盘剩余空间不足的目录下。这一步很多人忽略,后面容器一多,磁盘I/O会直接拖垮整个运行体验。

下载Docker Desktop安装包的时候,Apple Silicon芯片和Intel芯片的版本是分开的,注意选对。安装完成后,打开终端执行docker --version和docker compose version验证是否装好。如果docker命令提示command not found,F大概率是安装时没有选择把命令行工具链接到/usr/local/bin,重新安装一次,或者在偏好设置里勾选“Use the bundled CLI tools”能解决。

还有一个很容易踩的坑:Docker Desktop启动之后,容器运行所需的后台进程是在一个轻量级虚拟机里跑的,不是直接跑在macOS的进程里。这意味着你在容器里看到的文件系统、网络端口,和宿主机并不是完全一体的。比如你在容器里监听9000端口,在宿主机上不一定能直接通过localhost:9000访问到,除非你在启动容器时显式做了-p 9000:9000端口映射。这个逻辑如果不理解透,后面配置nginx和php-fpm联调时会绕不少弯路。

提示:如果你在公司网络环境里,Docker Desktop第一次拉取镜像时会比较慢,这是网络带宽问题,不是软件问题。多等一会儿或者换一个非高峰时段就好,别随便关掉拉取进程。

2. 部署思路:Nginx和PHP不是一个容器,是俩容器

很多人第一次上手Docker部署PHP项目,会有一个误区:以为是把Nginx和PHP装进同一个容器里。这里我先把结论说透:Nginx和PHP-FPM是相互独立的两个进程,在Docker环境里,最符合习惯的做法是分别运行两个容器,通过内部网络通信,Nginx负责处理HTTP请求和静态文件,PHP-FPM负责解析PHP脚本。为什么这样拆分?因为两个服务的生命周期不同,PHP代码更新时你不需要重启Nginx容器,Nginx配置变动时也不需要重建PHP容器,各管各的,职责分离,后面扩缩容也方便。

这两个容器之间是怎么协作的呢?当浏览器发起一个访问http://localhost/index.php的请求,请求先进Nginx容器,Nginx根据配置文件判断这个请求的后缀是.php,于是把请求转发给PHP-FPM容器处理。PHP-FPM容器里跑着的是PHP解释器,它把PHP代码执行后的结果返回给Nginx,Nginx再把HTTP响应返回给浏览器。这个过程中,Nginx和PHP-FPM之间的通信协议是FastCGI协议,默认走9000端口(或者通过Unix Socket通信)。

在docker-compose.yml里编排这两个服务时,最关键的一点是服务名。Nginx容器里配置fastcgi_pass php:9000而不是localhost:9000,因为Docker内部会为每个服务创建DNS记录,服务名就是域名。如果你在Nginx容器里写成了localhost:9000,等于让Nginx容器去连自己的9000端口,而Nginx容器里压根没有PHP-FPM进程,请求必挂。

设计思路理清之后,还有一个网络层面的问题:为什么不用--link参数,而推荐用docker-compose的默认网络?--link是Docker早期的容器互联方案,现在依然能用,但它不是项目级别的编排工具,每次启动容器都要手动指定连接关系,时间一长命令会越来越长。docker-compose会自动创建一个项目专属网络,同一个compose文件里的所有服务都自动加入这个网络,直接用服务名互相访问,配置集中在同一个YAML文件里,提交版本管理也方便。这使得整个部署流程更清晰,团队成员拿到一个目录就能复现整套环境。

再来看端口规划。整条链路有两条访问路径,第一条是从宿主机到Nginx容器,也就是宿主机端口:80这个映射,通常我们让宿主机监听80端口,访问http://localhost就到Nginx;第二条是从Nginx容器到PHP-FPM容器的9000端口,这条路径走的是Docker内部网络,不需要暴露到宿主机,所以PHP-FPM服务不需要配置ports映射,只需要暴露给Nginx容器即可,也就是expose: ["9000"]。我见过有人给PHP-FPM也配置了ports: - "9000:9000",这样宿主机也会监听9000端口,如果本机正好有其他程序占用9000端口,比如某个调试工具,就会导致PHP-FPM启动失败。所以记住一个原则:不是所有端口都需要映射到宿主机,容器之间通信不需要对外暴露。

2.1 为什么选docker-compose而不是docker run命令

如果你只跑一次容器,docker run确实够用,但是这次任务里有两三个服务要联动,再加上多个配置文件要挂载到容器里,用一行命令去启动会非常啰嗦。你想象一下这个场景:先docker run启动PHP-FPM容器,等它正常运行了,再docker run启动Nginx容器,Nginx启动时还需要通过--network参数加入和PHP-FPM相同的网络才能互访。中间任何一个参数写错,都要把容器删掉重建。

用docker-compose管理的话,服务之间的启动顺序、网络、数据卷挂载、环境变量全部声明在一个YAML文件里。执行docker compose up -d一次搞定,docker compose down一键停止并移除网络,整个生命周期的管理成本降低不少。调试的时候也可以只重启某个服务,比如改了Nginx配置,执行docker compose restart nginx即可,不会影响到PHP容器里正在运行的任务。

2.2 镜像版本选择的讲究

Docker Hub上的Nginx镜像有很多tag,常见的有最新版、稳定版、alpine版。我个人建议生产环境使用稳定版或alpine版本,而本地测试使用最新版也无妨。Alpine版体积小,差不多20MB左右,比默认的Debian版小了将近一半,如果你的机器磁盘空间紧张,选alpine能省不少空间。不过alpine版用的是musl libc,某些PHP扩展在alpine上编译时偶尔会报错,如果你打算后面在PHP容器里装扩展,稳妥起见用默认版本的PHP镜像会更省心。

PHP官方镜像有两个分支值得区分,一个是php:7.4-fpm,一个是php:8.2-fpm,它们的区别不仅在于PHP版本号,还在于默认编译时的配置参数。FPM版本只包含PHP解释器和FPM进程管理器,不包含Apache,所以必须配合Nginx或者其他Web服务器使用,这正好符合我们的场景。还有一个细节:镜像tag里有-fpm后缀的,说明内置了PHP-FPM功能,可以直接和Nginx配合,不需要再单独安装;没有这个后缀的是CLI版本,只适合跑命令行脚本。

3. 实操准备:目录结构与配置文件逐个击破

部署前先把文件夹结构规划好,这一项直接关系后续维护的效率。我在本机习惯把所有项目都放在~/developer/目录下,统一用工程名建文件夹。比如模拟项目X的部署目录就是这样规划的:

~/developer/sim-demoproject/ ├── docker-compose.yml ├── nginx │ ├── conf.d │ │ └── default.conf │ └── logs ├── php │ ├── Dockerfile │ └── php.ini └── html └── index.php

nginx/conf.d目录放站点配置文件,nginx/logs挂载Nginx日志,html放PHP项目代码,php/Dockerfile用于构建带有额外配置的PHP镜像。这个结构并不复杂,但逻辑上清清楚楚:配置文件归配置文件,代码归代码,日志归日志,互不纠缠。

接下来一个一个写配置文件。

3.1 编写Nginx站点配置

Nginx的默认站点配置文件名我习惯用default.conf,放置在nginx/conf.d目录下,Nginx容器启动时会自动加载这个目录下所有以.conf结尾的配置文件。内容如下:

server { listen 80; server_name localhost; root /var/www/html; index index.php index.html; access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }

有几个位置需要特别说明。root的值是/var/www/html,这个路径对应的是Nginx容器内部的文件系统路径,不是宿主机路径。为什么是这个路径?因为我们后面在docker-compose里做数据卷挂载时,会把宿主机的html目录挂载到Nginx容器的/var/www/html目录,这样Nginx才能在容器内部读到PHP文件,而不用把文件复制进镜像里。数据卷挂载是实现本地代码即时同步的关键,代码改动后只需要刷新浏览器,不需要重新构建镜像。

location ~ \.php$这一段是核心,它定义了当请求的URI以.php结尾时,Nginx将请求转发给上游PHP服务。fastcgi_pass php:9000里的php就是docker-compose里PHP-FPM服务的服务名,这个名称不是随便取的,必须和docker-compose.yml里的service名一致,否则Nginx容器解析不到这个主机名,请求返回502错误。

SCRIPT_FILENAME参数也要仔细看。$document_root$fastcgi_script_name意味着PHP-FPM将根据这个路径查找并执行脚本。如果配置成$request_filename或者其他写法,某些情况下PHP文件会找不到,返回404或者空白页。我见过不少人踩过这个坑,实际报错并不直接告诉你路径错了,而是表现为访问任意PHP文件都404,排查时浪费了大量时间。后来我养成一个习惯:配置文件写好后,先在宿主机上用docker exec命令进入Nginx容器,查看/var/www/html目录是否存在、内容是否同步,再考虑其他问题的可能性。

3.2 编写docker-compose.yml

接下来是重头戏,docker-compose.yml内容如下:

version: '3.8' services: php: image: php:8.2-fpm container_name: sim-project-php volumes: - ./html:/var/www/html networks: - sim-net nginx: image: nginx:latest container_name: sim-project-nginx depends_on: - php ports: - "8080:80" volumes: - ./html:/var/www/html - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/logs:/var/log/nginx networks: - sim-net networks: sim-net: driver: bridge

这里有几个关键点逐一说明。version字段通常也不需要太纠结,docker-compose v2之后,这个字段值更多是声明式的,3.8兼容性已经很高,用这个不会出错。php服务直接使用官方php:8.2-fpm镜像,没有添加任何额外的构建配置,现阶段够用就行。nginx服务使用nginx:latest,同样保持简单。

ports配置我写了"8080:80"而不是"80:80",这是有意为之。macOS系统本身可能占用80端口来跑AirPlay相关服务,如果你没遇到过还好,一旦遇到端口冲突,Nginx容器启动就会报错,报错信息很长一串,新手很容易被吓住。映射到8080端口的好处是百度没有后顾之忧,访问时用http://localhost:8080即可。如果实在想用80端口访问,先检查本机有没有占用,再sudo lsof -i :80查看,确认空闲后再改这个映射。

volumes配置里,PHP服务和Nginx服务都挂载了相同的./html:/var/www/html目录,这是为了保证两边看到的是同一份代码,Nginx转发PHP请求时,PHP-FPM才能定位到同一个文件。看似简单的设计,实际是容器化部署PHP应用最常用的模式。

depends_on字段表示Nginx服务依赖PHP服务先启动。但依赖不代表Nginx会等PHP完全就绪再启动,它只是控制启动顺序,如果PHP容器启动过程比较慢,Nginx启动后短暂时间探测不到上游服务,客户端请求还是会报502,不过不用紧张,重试一次就正常了。

container_name我设置了固定的容器名,好处是后续命令行操作时能一眼辨认容器身份,坏处是如果这套环境要在多台机器上同时运行,遇到写入同名容器会冲突。日常开发场景设置固定名称问题不大。

为了更直观对照,我把关键配置项整理成了表格,方便你检查自己写的compose文件有没有遗漏:

配置项PHP服务Nginx服务
镜像php:8.2-fpmnginx:latest
容器名sim-project-phpsim-project-nginx
宿主机端口映射无8080 -> 80
内部网络sim-netsim-net
挂载目录./html./html、./nginx/conf.d、./nginx/logs
上游关联无php:9000

3.3 准备PHP测试文件

在html目录下新建一个index.php,文件内容用一个最简单的探针页:

<?php phpinfo();

保存后,这个文件就会通过docker-compose的数据卷挂载同步到Nginx容器和PHP容器里。敲一下docker compose up -d,再访问http://localhost:8080/index.php,如果能看到PHP信息页面,说明Nginx解析PHP请求并转发给PHP-FPM的整个链路已经跑通了。

建议等这个最简单的探针页成功之后,再把实际业务代码放进来。很多人一上来就把整个项目代码拷贝进去,万一环境有问题,根本分不清是配置问题还是代码兼容性问题。先跑通最小链路,其他问题都好说。

4. 启动与验证:从命令行到浏览器一次全通

完成配置文件编写后,整个部署过程就进入命令行操作阶段。先解释一个容易混淆的概念:这里说的“启动”不是直接docker run,而是先进入到刚才创建的sim-demoproject目录,再执行docker compose系列命令。

cd ~/developer/sim-demoproject docker compose up -d

-d参数让容器在后台运行,不占用当前终端窗口,这符合平时的使用习惯。命令执行顺利的话,终端会打印出网络创建成功、两个容器启动成功的日志。接着用docker compose ps检查容器状态,输出结果里两个服务状态都应该是Up。

如果一切正常,浏览器打开http://localhost:8080,看到PHP探针页面。这就是整个环境跑通的最直观证据。从打开浏览器到刷新出结果,几秒钟的事,速度比在本地装一套XAMPP还要快,这也是容器化带给开发者的爽点:环境启动可以随时拆掉重建,不污染真实系统。

4.1 遇到端口冲突时怎么处理

不是所有事情都那么顺利。假如你在macOS上发现Nginx容器怎么都起不来,大概率是8080端口被某个进程占了。可以用这个命令排查:

lsof -i :8080

输出内容里会列出占用8080端口的进程PID,记下PID后执行kill -9 PID,把占用端口的进程终结掉,再执行docker compose up -d即可。如果不想动那个进程,也可以把compose文件里的端口改成"8081:80",灵活处理。

还有一种情况,Nginx容器启动成功了,但访问http://localhost:8080返回的是404而不是PHP探针页。这通常说明Nginx找不到index.php文件。这时进入Nginx容器验证一下挂载目录内容:

docker exec -it sim-project-nginx ls -la /var/www/html

如果目录里能看到index.php,说明数据卷挂载没问题,问题更可能出在Nginx配置的root路径上,回到nginx/conf.d/default.conf,仔细检查root /var/www/html;是不是写成了/var/www/html/(末尾多了斜杠也有影响),或者写成了/usr/share/nginx/html(默认路径,但挂载目录未必同步)。这一步排查思路很重要,直接解决问题。

4.2 PHP-FPM连接失败的经典错误

如果访问http://localhost:8080/index.php返回502 Bad Gateway,就说明Nginx已经收到了请求,但无法从上游PHP服务获取响应。最常见的因素有三个:

第一个是fastcgi_pass配置的上游主机名错误。检查nginx/conf.d/default.conf里的fastcgi_pass是否写成了127.0.0.1:9000,如果是,改成php:9000。原因前面已解释,Docker内部网络采用服务名解析,书写本机回环地址相当于请求Nginx容器自身。

第二个可能因素是两个服务没有加入同一个网络。检查docker-compose.yml里的networks部分,确保php和nginx都声明了同一个网络(比如sim-net)。如果php服务忘了声明网络,它会在默认网络里,Nginx所在的sim-net是无法解析到它的。

第三个是PHP-FPM容器实际还没就绪。用docker logs sim-project-php查看PHP容器日志,如果输出正常,说明FPM已经起来了;如果输出里一堆错误还带着只监听127.0.0.1:9000什么的,就被暗示配置文件有问题了。通常官方php-fpm镜像默认监听0.0.0.0:9000,理论上不需要额外配置,但万一你之前改过一些东西,就到容器内部查看监听地址:

docker exec -it sim-project-php php -S 0.0.0.0:9000 -t /var/www/html

不过这条命令是立即启动内置PHP服务器,用途上不是用来诊断的,更合适的做法是直接docker exec -it sim-project-php netstat -tlnp,查看9000端口监听情况。如果容器里没有netstat命令,也可以进入容器后查看/usr/local/etc/php-fpm.d/下的配置。

4.3 修改Nginx配置后的生效方式

Nginx配置文件是静态文件,挂载进容器之后不会自动加载新配置。改了default.conf后,要让Nginx重新读取配置,优先使用这个命令:

docker exec -it sim-project-nginx nginx -s reload

这个命令的作用类似向Nginx主进程发送HUP信号,它会平滑地重新加载配置,不影响正在处理的请求。整个过程很快,不用重启容器,也不会造成服务中断。

如果你改了php.ini或者环境变量这类配置,那需要先重建PHP容器才能生效:

docker compose restart php

restart会先停止再启动容器,但不会重新创建容器,也没有应用新的镜像和环境变量;如果改了docker-compose.yml里php服务的内容,则需要:

docker compose up -d --force-recreate php

加了--force-recreate参数才会强制重新创建容器实例,这样compose文件里的改动才会真正生效。这些命令的区别平时用熟了就觉得顺手,但刚上手时很容易摸不着头脑,反正记住一条主线:改配置文件用reload或restart,改docker-compose原生配置用up -d重建。

5. 常见问题与排查技巧实录

5.1 每次改代码都不生效

这个问题经常在Nginx和PHP都跑通之后出现。明明把新代码放到了宿主机的html目录,浏览器刷新却还是旧页面。原因大概率来自Nginx缓存或者浏览器缓存。

Nginx默认不会主动给PHP响应做缓存,因为PHP输出是动态内容,一般不命中Nginx的静态文件缓存。但如果你用的是默认配置之外的东西,比如给静态资源单独设置了缓存头,那浏览器就很能缓存住了CSS、JS这类静态文件。最省事的验证办法是启动浏览器的无痕模式重新访问,或者直接curl看响应头:

curl -I http://localhost:8080/

如果响应确实更新了,说明服务本身没问题,是浏览器缓存搞的鬼,按Cmd+Shift+R强制刷新即可。代码同步不畅还有一种可能,是你的IDE或者代码版本工具在写文件时创建了隐藏文件或者软链接,Docker数据卷对软链接的处理有时并不符合预期,建议使用真实的文件目录而不是符号链接。

5.2 容器日志快速定位错误

排查问题最有效的手段就是看日志。docker-compose提供了两个方向的日志查看方式:

# 查看某个服务的完整日志 docker compose logs nginx # 跟踪某个服务的实时日志 docker compose logs -f php

-f参数可以持续跟踪日志输出,可以一边操作浏览器一边观察后端日志。Nginx的错误日志会记录访问失败的原因,比如连接被拒绝、脚本不存在、权限拒绝等,这些信息对排查问题帮助巨大。以前我用本地集成环境跑PHP项目,日志分散在不同目录,找起来很麻烦;现在所有日志都写到nginx/logs目录下,在IDE里打开一个文件就全看到了,确实舒服。

这里给一个实际案例,某开发者的环境里PHP页面总是周期性地返回502,查日志发现PHP-FPM日志里出现非常多 “connection reset by peer”,反复看都觉得莫名其妙。后来发现是PHP容器内存不足,被Docker守护进程强制杀掉重启,时间一长就表现为偶发502。查根因之后把内存分配加大,问题就消失了。这个案例说明:很多容器问题并不在业务代码层面,而是在资源供给层,别只顾着看应用日志,也要留意容器本身的状态。

5.3 进入容器调试的技巧

虽然大部分问题通过日志能定位,但偶尔还是要“钻”进容器里面看一眼。进入容器用如下命令:

docker exec -it sim-project-php bash

执行后,你会进入PHP容器的/var/www/html目录。用ls能看到项目文件是否存在。用php -v验证PHP版本。用php -m查看已加载的扩展模块。如果某个扩展缺失,你之前定的基础镜像又无法直接安装,那就得考虑编写自己的Dockerfile,在官方镜像之上通过docker-php-ext-install命令添加扩展。举个例子,比如项目需要一个MySQL扩展,Dockerfile写起来大概是这样:

FROM php:8.2-fpm RUN docker-php-ext-install pdo pdo_mysql

然后在docker-compose.yml里把php镜像的image字段替换成build: ./php,重新执行docker compose up -d --build就能构建一个带MySQL扩展的PHP镜像。这也是容器化部署中常见的“构建专属镜像”思路,平时用官方镜像方便,需要定制时就在镜像之上叠加扩展,整个过程干净可控。

5.4 文件权限问题的处理

容器内的进程用户和宿主机用户并不一致,默认情况下Nginx容器内部以nginx用户运行,PHP容器内部以www-data用户运行。如果宿主机目录下的文件权限设置不合理,容器内进程可能没有读权限,前端页面就会出现空白或者403。

粗浅的做法是在挂载目录里对所有文件执行chmod -R 755,这能解决大部分读权限问题。如果PHP代码运行过程中需要写入文件,比如日志文件、缓存目录,就需要把这些写入目标目录的权限放宽,比如chmod -R 777 html/runtime(这里只是开发环境图省事,生产环境别这么干,用系统账号映射或者user参数合理控制)。macOS的文件系统对权限的容忍度比Linux高一些,但还是会出现容器内进程读不到文件的情况,遇到问题先用docker exec进入容器用cat /var/www/html/index.php看能不能读到内容,精确判断是权限问题还是路径问题。

5.5 资源占用过高

容器化环境跑起来之后,有时候发现CPU占用很高,特别是在Mac的Docker Desktop上。常用的处理办法是看stats:

docker stats

这个命令会实时显示每个容器的CPU、内存、网络I/O,能直观看到哪个容器在消耗资源。如果PHP容器CPU一直飙高,很可能代码里有死循环,或者用了不合理的sleep策略;如果Nginx CPU高,多半是有异常流量或者配置了太激进的反向代理缓冲。对于日常开发环境,系统资源本来就是大家共享的,别开太多不用的容器,用完就docker compose down,保持清爽。

6. 容器部署之外:日常维护与项目扩展

环境搭好只是第一步,真正体现工程化能力的,是日常维护和可扩展性。这套以docker-compose为核心的部署结构,具备了很好的扩展空间。

新增加一个Redis服务,只需要在compose文件里追加声明:

redis: image: redis:7-alpine container_name: sim-project-redis ports: - "6379:6379" networks: - sim-net

然后PHP代码里连接Redis的主机名直接写redis即可。不需要额外调整Nginx配置,也不需要修改网络互访,新的服务自动加入sim-net网络,这样的横向扩展模式,比在物理机上折腾一堆服务要舒服太多了。

目前这套环境的启动成本已经很轻,整个项目小几十MB,还不到一张高清图的大小。迁移到另一台电脑时,只需要把整个项目目录拷贝过去,拉到同版本镜像,执行docker compose up -d,全部环境就能在十分钟内恢复。配合版本控制工具提交代码时,连配置文件一起提交,后续在什么环境下都能保持一致,不用再担心“在我电脑上是好的”这种话。

我也是踩了不少次坑之后才总结出这套流程的。回想一下,起初我为了图省事把所有服务都封装进同一个容器,觉得自己写个Dockerfile把Nginx和PHP都装进去就完事了,结果每次改配置都要重建整个镜像,部署一次少说要等三五分钟。改成双容器加docker-compose编排之后,改Nginx配置几十秒内就能完成热加载,效率完全是两个档次。

最后再分享一个小技巧:如果你也是在macOS上做日常开发,别把浏览器直接访问的目标地址记成8080端口,可以在终端里用一行命令做一次端口转发代理,把80端口的请求转发到8080:

sudo pfctl -s nat

不过这属于系统级网络配置,考虑性价比不高。如果没有特殊要求,直接用8080端口访问就好,实际使用中并不影响开发调试。总之,Docker环境搭好之后,整个工作流会自然顺畅起来,这些配置知识也会在后续不同的开发场景里持续派上用场。

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

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

立即咨询