最近在macOS上折腾Docker,目标很明确:把Nginx+PHP这套经典组合放到容器里跑通。折腾完发现,这类教程网上虽然不少,但很多只给命令不讲为什么,遇到502和404容易一脸懵。这篇文章把从安装Docker到最终访问PHP页面的全过程写一遍,把我踩过的坑、改过的配置、排查问题的顺序都记录下来,适合刚接触容器、想在本地快速跑起PHP环境的开发者参考。
1. 安装Docker:先确认芯片型号再动手
macOS上跑Docker和Linux上有本质区别。Linux本身支持容器,macOS内核不支持Linux容器,所以必须借助一层轻量虚拟机。Docker Desktop在macOS上就是做了这件事:后台跑一个Linux虚拟机,所有容器都在这台虚拟机里运行。你可以把容器理解成标准化的货柜,而Linux内核是码头装卸设备,macOS没有这套设备,只能先虚拟一个码头出来。这个底层机制搞清楚之后,后面遇到文件挂载、资源占用这些问题就比较好理解了。
安装前第一件事是先确认自己这台Mac的芯片型号。命令行执行:
uname -m输出arm64表示Apple Silicon(M系列芯片),输出x86_64表示Intel芯片。这个信息在后面拉镜像、遇到平台兼容问题时很关键。普通用户直接装Docker Desktop一般不需要手动指定架构,Docker会自动根据当前架构拉取对应平台的镜像,但偶尔会碰到某些老镜像只有x86_64版本。Apple Silicon机器上可以加platform: linux/amd64强制跑,代价是性能差一些,本地调试勉强能用。提前知道这个知识点,遇到报错时就不会一头雾水。
1.1 用Docker Desktop安装,还是用轻量工具
新手我推荐直接用Docker Desktop。原因很直接:安装简单,自带图形界面,网络、磁盘、资源占用都能可视化调整,对刚接触容器的人最友好。安装包从官方页面下载dmg,双击拖进Applications即可。第一次启动会提示允许一些扩展权限,需要输入本机密码,这个不是异常,直接确认。
如果不想用图形界面,或者觉得Docker Desktop太重,也可以考虑Colima这类轻量方案。Colima在命令行里启动一个Linux虚拟机,和Docker CLI配合使用。优点是资源占用小、命令可控,缺点是对不熟悉命令行的人不友好,而且配置网络端口时偶尔需要额外参数。我的建议是:本地学习阶段先用Docker Desktop跑通,等理解了容器基本概念再考虑轻量方案,没必要一开始就给自己增加心智负担。
安装完之后打开Docker Desktop,等右下角图标变成稳定状态,再在终端验证:
docker --version docker compose version如果两个命令都能输出版本号,说明安装成功。还可以跑一个最小镜像确认整个链路是通的:
docker run --rm hello-world这个镜像体积很小,输出一段欢迎信息就退出了。这一步能同时验证客户端、守护进程、镜像拉取这几个核心环节是否正常。我第一次装完跑这一步的时候卡了很久,后来发现是虚拟机还没完全启动,多等几秒再试就好了。
1.2 镜像版本选择:为什么推荐alpine
项目里要用Nginx和PHP的官方镜像,具体标签有很多种。我推荐Nginx用nginx:stable-alpine,PHP用php:8.2-fpm-alpine。alpine是种极简Linux发行版,体积小,镜像通常只有几十兆,拉取快、占用磁盘少,很适合本地调试。Debian系镜像功能更全,但体积动辄几百兆,本地起个环境完全没必要用那么重的东西。
选php:8.2-fpm-alpine而不是php:8.2-apache也很讲究。前者只带PHP-FPM,不包含Web服务器,正好和Nginx搭配;后者自带Apache,如果你不需要Apache,就别用。项目里如果只是跑PHP脚本、不接数据库,直接拿官方镜像就能用;如果以后要装PDO、mysqli这类扩展,就需要自己基于官方镜像写一个Dockerfile。这个后面的章节会详细说。
2. 先把项目目录和配置规划好
很多新手学Docker时习惯在一堆命令里直接docker run拼参数,容器一多就变成一坨看不懂的咒语。我经历过这些之后,强烈建议用docker-compose来管理。它的核心价值是把多个容器的启动参数写进一个yaml文件,一条命令启动、一条命令停止,换台机器也能复现同样的环境。
2.1 一个干净到能直接抄作业的目录结构
在动手写配置前,先在本地建一个项目目录。我的习惯是:
web/ ├── docker-compose.yml ├── conf/ │ └── nginx/ │ └── default.conf └── www/ └── index.phpconf放Nginx相关配置,www放PHP代码文件,docker-compose.yml放容器编排配置。为什么刻意把配置和代码分开?因为后面Nginx容器需要把配置文件挂载进去,如果把配置放在代码目录里混着,修改一个文件就会牵动整个项目结构,后期维护很麻烦。分开之后,代码目录干干净净,配置变更也能单独追踪。
代码文件在www/index.php里随便放一段测试内容:
<?php echo "hello, nginx + php in docker";先不放phpinfo()这种大页面,等下排查问题时会更清晰。等确认能跑通了,再换成phpinfo()验证扩展和环境变量也不迟。
2.2 镜像选型的三个判断标准
镜像怎么选,我总结成三点。第一看基础系统:alpine精简、debian万全,本地优先alpine。第二看进程模型:Nginx要选官方nginx镜像,PHP不能选php:apache那种自带Web服务器的,而选php:fpm或php:fpm-alpine。第三看版本:要用自己熟悉的PHP版本,比如项目依赖老语法就选php:7.4或php:8.0,没有特殊要求就选8.2,稳定且社区资料多。
这里还有个容易忽略的点:不要一看到“官方”两个字就以为镜像里什么都有。PHP官方镜像默认只带很基础的扩展,数据库驱动、GD库这些大部分都没有。后面如果发现php -m里缺扩展,不要慌,这是默认状态,需要自己加。这部分我会在3.4小节给出一个可以直接用的Dockerfile。
2.3 nginx为什么必须搭配php-fpm
Nginx本身不执行PHP代码,它收到一个.php请求时,只会把它当作一个普通文件,要么返回内容,要么404。要让Nginx把PHP请求交给真正的解析器,就得靠FastCGI协议。PHP-FPM就是PHP的FastCGI进程管理器,它监听一个端口(默认9000),等待Nginx把请求转发过来,然后解析PHP并把结果原路返回。
用生活场景打比方:Nginx是前厅接待,负责收请求、处理静态文件这些“迎客”的事;PHP-FPM是后厨,负责真正“做菜”,也就是执行PHP脚本。两者之间通过一条固定的传菜窗口通信,这个窗口就是端口9000。在Docker编排里,Nginx容器和PHP容器需要处于同一个容器网络里,Nginx才能访问到php:9000这个地址。这就是为什么compose里要显式声明网络,而不是简单把两个容器跑起来就完事。
3. 用docker-compose把两个容器编排起来
3.1 docker-compose.yml逐段拆解
直接在项目根目录创建docker-compose.yml:
services: nginx: image: nginx:stable-alpine container_name: web-nginx ports: - "8080:80" volumes: - ./www:/var/www/html:ro - ./conf/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - php networks: - webnet php: image: php:8.2-fpm-alpine container_name: web-php volumes: - ./www:/var/www/html networks: - webnet networks: webnet: driver: bridge不用写顶部version字段,新版docker compose已经忽略它了,写了反而容易误导。services下面定义两个服务:nginx和php。nginx里image指定用哪个镜像,container_name给容器起名,ports把宿主机8080端口映射到容器内80端口。为什么宿主机不直接用80?因为macOS上很多软件会占用80端口,或者权限限制,导致绑定失败。用8080这种高位端口,能避开绝大多数冲突。
volumes这段是精华。./www:/var/www/html:ro把宿主机当前目录下的www目录挂载进Nginx容器内的/var/www/html,只读;./conf/nginx/default.conf挂载为Nginx的default.conf,只读。PHP容器也把./www挂载进/var/www/html,但没加:ro,因为PHP-FPM可能要写文件。两个容器共享同一个宿主机目录,Nginx才能把请求转给PHP后正确找到对应的脚本文件。
3.2 先搞清楚depends_on的局限性
depends_on: - php表示Nginx容器会在PHP容器启动之后才启动。但注意,它只管启动顺序,不管服务是否已经准备好接收请求。PHP-FPM容器的9000端口可能要过几秒才真正就绪,这时候Nginx可能已经启动、尝试连接php:9000但失败。表现在浏览器上通常是短暂的502,过几秒刷新就好了。
如果希望避免这个“启动瞬间的502”,可以给PHP容器加一个健康检查,让Nginx等待它健康后再启动。compose标准里有healthcheck和condition: service_healthy的写法,但对于本地开发环境,我一般不折腾这层,毕竟只是启动那几秒的事。理解了“depends_on不保证就绪”这个坑,比单纯抄配置更值钱。
3.3 nginx配置文件:fastcgi_pass是关键
在conf/nginx/default.conf里写一套最精简的可运行配置:
server { listen 80; server_name localhost; root /var/www/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 $document_root$fastcgi_script_name; include fastcgi_params; } }listen 80对应容器内的80端口,宿主机的8080映射到这里。root /var/www/html必须是两个容器共享的那个目录在Nginx容器里的路径。如果这里的路径和实际挂载路径对不上,最常见的表现是访问首页出现404,而nginx容器里文件确实存在。
最重要的一行是fastcgi_pass php:9000。这个php不是域名,是compose里的服务名。Compose会在默认网络里为每个服务生成一个DNS记录,所以Nginx容器可以直接用服务名访问PHP容器。如果不用compose、手动用docker run,这里就要写成容器的IP或者加了--link的别名,比较麻烦,这也是我推荐compose的原因之一。
SCRIPT_FILENAME这个参数也很关键。Nginx要把请求转发给PHP-FPM时,需要告诉PHP-FPM“你要解析的文件完整路径是什么”。$document_root$fastcgi_script_name组合起来就是容器内的/var/www/html/index.php。以前常见错误是只写$fastcgi_script_name,结果路径不完整,PHP-FPM找不到文件,返回404或白屏。
3.4 PHP容器加扩展:一个简单的Dockerfile
直接用官方php:8.2-fpm-alpine镜像能跑纯PHP,但真实项目几乎都要接MySQL、连Redis,至少PDO扩展是少不了的。这时候最好的做法不是手动进容器里装,而是写一个Dockerfile把扩展固化下来。
在项目目录下建docker/php/Dockerfile:
FROM php:8.2-fpm-alpine RUN apk add --no-cache tzdata \ && docker-php-ext-install pdo_mysql mysqli ENV TZ=Asia/Shanghai然后用build替代image:
php: build: ./docker/php container_name: web-php volumes: - ./www:/var/www/html networks: - webnetdocker-php-ext-install是PHP官方镜像里自带的脚本,专门用来编译安装扩展。apk add tzdata是为了处理容器时区问题,默认容器是UTC时间,如果PHP业务里用到date()函数,会发现比北京时间少8小时。加了ENV TZ=Asia/Shanghai后,新起的PHP进程就会读取这个时区。注意,改了Dockerfile后要重新构建:
docker compose build php docker compose up -d如果只是想在本地快速验证,不接数据库,也可以完全跳过Dockerfile,直接用官方镜像跑。扩展方案遇到再说,不要一步到位把配置搞复杂。
4. 从启动到验证,完整走一遍
4.1 启动容器并确认状态
配置都写好后,启动就特别简单:
cd web docker compose up -d docker compose psup -d会在后台启动所有服务。docker compose ps能看到容器运行状态,如果STATUS是Up说明启动了。Nginx容器会显示端口映射0.0.0.0:8080->80/tcp。这个输出信息很有用,后面排查有没有绑定成功都靠它。
然后访问首页:
curl http://localhost:8080如果返回了我们写的hello, nginx + php in docker,说明整个链路已经通了。用浏览器打开也一样。第一次跑通的时候会有点成就感,因为这中间Nginx、PHP-FPM、容器网络三个环节任何一个出错,结果都不是这个。
4.2 如果出现502,按这个顺序排查
本地搭建最常见的错误就是502。出现502时先从日志看:
docker compose logs nginx日志里如果出现connect() failed (111: Connection refused) while connecting to upstream,说明Nginx容器连接php:9000失败。接下来依次确认三件事:一,PHP容器是否在运行,docker compose ps看到web-php状态;二,compose网络里服务名是不是php,和fastcgi_pass php:9000是否一致;三,PHP-FPM到底有没有监听9000端口。
第三点很多人会忽略,可以在PHP容器里直接验证:
docker compose exec php sh -c "netstat -tuln | grep 9000"有些精简镜像可能没装netstat,也可以用ss -tuln或者php-fpm -t测试配置语法。如果9000端口没有监听,多半是PHP-FPM启动失败,这时看PHP容器日志:
docker compose logs php日志里会给出具体错误,比如配置文件路径不对、用户不存在等。按提示处理即可。
4.3 如果出现404或直接下载PHP文件
404通常不是网络问题,而是文件路径问题。确认一下Nginx容器里能不能看到代码文件:
docker compose exec nginx ls -l /var/www/html如果看不到index.php,说明挂载有问题,重新检查compose里的volumes映射。如果能看到文件但还是404,重点看root路径和index指令,没有index index.php时Nginx不会自动把请求交给PHP索引文件。
还有一种现象是浏览器直接下载.php文件而不是解析,这说明Nginx根本没用location ~ \.php$那段配置处理PHP请求,请求被当成普通静态文件返回了。检查一下挂载的配置文件是否真的生效,可以在Nginx容器里:
docker compose exec nginx cat /etc/nginx/conf.d/default.conf如果看到的内容和你本地的default.conf不一致,说明容器里的配置文件没有更新,需要重启Nginx容器。
4.4 用phpinfo验证环境细节
能访问到PHP内容后,可以把index.php临时改成:
<?php phpinfo();再刷新页面,就能看到PHP版本、配置项、已加载模块等一整套信息。重点看两个地方:PHP Version确认版本和镜像里一致;Server API显示FPM/FastCGI,说明PHP确实是由PHP-FPM解析的。phpinfo()信息量很大,本地调试用起来特别方便,但生产环境绝对不要暴露给外部访问,这点要养成习惯。
想看PHP扩展,也可以直接进容器:
docker compose exec php php -m这个命令会列出所有已安装模块,比如PDO、pdo_mysql、mysqli如果装了就都会在里面。整个排查链路走下来,基本覆盖了这类组合环境90%的启动问题。
5. 常见问题与避坑速查
整理了一份常见问题表,按现象直接查,比反复看日志省时间:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 启动报端口被占用 | 8080被宿主机其他进程占用 | 改compose里"8080:80"为"8081:80" |
| 502 Bad Gateway | Nginx连不上php:9000 | 检查PHP容器状态、服务名、9000端口监听 |
| 404 Not Found | 根路径或文件名不匹配 | 检查root路径、挂载和index指令 |
| PHP文件被下载 | 没有走location ~ \.php$ | 检查nginx配置是否生效 |
| 修改代码后不生效 | 本地文件和容器内路径不一致 | docker compose exec nginx ls确认挂载 |
| 时区差8小时 | 容器默认UTC | 在Dockerfile装tzdata并设置TZ |
5.1 端口被占用别硬刚,换个端口
macOS上“端口被占用”几乎是必踩的坑。我遇到过好多次,不是系统服务占用了80,就是别的项目占用了8080。改端口不是丢人的事,本地开发本来就该避开常用端口。把compose里的ports改成"8081:80",再docker compose up -d重建就好了。不用改Nginx里的listen 80,那是容器内部端口,宿主机端口只由ports这行控制。
5.2 docker compose down到底删了什么
docker compose down会停止并删除容器,但不会被删除挂载的宿主机目录,所以代码文件不会丢。加了-v之后会连匿名卷一起删,如果数据放在命名卷或宿主机目录里,一般也不受影响。但我在早期不熟悉时,有一次执行down -v把数据库容器里的数据卷删了,当场懵掉。这里我的建议是:本地项目里凡是需要持久化的数据,都明确挂载到宿主机目录,比如./data:/var/lib/mysql,永远不要把数据只放在容器里。容器是随时可以重建的,数据不是。
5.3 Docker Desktop资源占用和清理
Docker Desktop默认分配的资源和本机配置有关,跑两个容器可能很轻,但如果同时开很多容器会吃内存。打开Docker Desktop设置,可以调高CPU和内存限制,调完需要重启Docker服务。另外,镜像越拉越多,磁盘会被撑满。清理时我用docker system prune清理悬空镜像、停止的容器和无用资源,会提示确认。这个命令会删除停止的容器和悬空的镜像,谨慎使用,但磁盘吃紧时真的管用。
6. 一点配置心得和后续扩展
6.1 为什么这套组合适合本地开发
很多人觉得在本地装Docker麻烦,不如直接装个Nginx、PHP。我个人的体会是:Docker方案最大的价值不在“安装”,而在“一致性”。一份docker-compose.yml放进项目仓库,不管换电脑还是换同事,只要装了Docker,一条命令就能复现同一个环境。再也不用为“我本机是PHP 7.4,你本机是8.2,跑出来结果不一样”这种问题浪费时间。如果你经常做PHP相关开发,这几乎是最省心的本地环境方案。
6.2 在这个基础上还能加什么
跑通Nginx+PHP只是第一步。后续最自然的扩展是加数据库和缓存服务,在compose里再加一个mysql和redis服务,PHP容器通过安装对应扩展连接它们,整个架构就和真实项目非常接近了。再往后,可以把PHP项目的代码通过Dockerfile直接打包进镜像,彻底摆脱源码目录挂载,变成一套可交付的环境。
举个例子,加MySQL服务时,compose里新增一个服务,指定镜像、环境变量、数据卷,再把PHP容器挂到同一个webnet网络里。PHP代码里连接数据库的主机地址直接写服务名而不是127.0.0.1,因为容器之间不能通过localhost互相访问。这类扩展并不难,但需要理解容器网络的基本逻辑。
最后分享一个我自己的小习惯:每配置完一个服务,都会单独验证这个服务是否可用,而不是等所有配置写完才启动。比如先只启动PHP容器,确认php -m正常;再启动Nginx容器,确认静态文件可访问;最后把两者连起来验证动态解析。分段验证能让问题在最简单的上下文里暴露,排查起来轻松很多。这套组合跑通之后,后面不管加MySQL还是Redis,我心里都有底了。