你在macOS上装过nginx和php吗如果装过大概率体会过那种“装起来半小时、调起来一晚上”的滋味。今天这篇我就带你完整走一遍macOS上安装Docker然后在Docker里部署一套nginxphp环境的过程整个过程不追求什么高大上的编排技巧但保证每一步都能落地、都能复现尤其是那些只有实际踩过坑才知道的细节我会一一说出来。这套东西适合谁如果你是前端开发想本地联调PHP接口如果你是刚入门的后端想在不污染本机的情况下快速起一套LNMP环境或者你只是想把本地开发环境做得跟线上服务器尽量一致那这篇内容就是给你准备的。看完之后你不仅能跑起来一个nginxphp的容器组合还能理解它们之间是怎么通信的以后自己加扩展、改配置、排故障都不至于两眼一抹黑。1. 先从“为什么”说起macOS 上为什么要用 Docker 跑 nginx php1.1 本地开发环境的一团乱麻我相信很多人都经历过这样一个阶段拿到一台新Mac想着装个nginx、装个php结果一搜教程各种brew命令、各种配置文件路径装完还得改php.ini、改nginx.conf好不容易跑起来过几天又要装个扩展把php又搞崩了最后干脆重装系统。这不是你笨而是传统方式装出来的环境本来就很难维护。nginx、php、MySQL各自依赖不同的动态库php版本和扩展之间还有兼容性问题你系统里可能还残留着旧版本的开机启动项。再加上公司项目和自己的项目可能需要不同版本的php这种“一个系统跑一套固定环境”的模式天然就捉襟见肘。我之前有段时间就是被这种事情反复折磨。后来换了个思路本地环境全部容器化本机只装一个Docker剩下的事情全部交给容器。系统干净了项目环境也互不干扰了。这大概是你在macOS上折腾开发环境时最值得做的一个决定。1.2 Docker 恰恰解决的是“环境一致性”Docker的本质用一个生活类比最好懂它就是一个标准化的集装箱。开发者把应用和它需要的所有依赖比如php、nginx、扩展、配置打包进一个镜像就像货物被装进集装箱不管运到哪装上船、卸下船、放到仓库里面的东西和环境都不变。以前你说“我这代码在我机器上能跑”现在你说“这套镜像在任何机器上都能跑”这就是环境一致性。对应到nginxphp场景里本机不用装nginx也不用装php只需要跑两个容器一个容器负责跑nginx一个容器负责跑php-fpm。nginx收到请求后遇到PHP文件就转交给php容器处理php处理完把结果返回给nginx再由nginx返回给浏览器。这个架构本身并不新但Docker让它变得特别容易在本地复现。你不用关心怎么在不同系统上编译安装软件只需要维护好一份docker-compose.yml和几个配置文件以后换电脑、同步给同事复制过去就能用。2. 开工前的准备Docker 安装与基础配置2.1 检查你的 Mac芯片架构与系统要求在装Docker之前先看一眼你的Mac是Apple Silicon还是Intel芯片。点左上角苹果图标选“关于本机”能看到“芯片”这一栏如果写着Apple M1/M2/M3这类字样就是Apple Silicon如果写着Intel就是传统的Intel芯片。这个区别在下载Docker Desktop的时候要用到。虽然现在Docker Desktop官网下载页会自动识别你的芯片但如果你去第三方渠道找安装包一定要分清楚x86_64和arm64版本。Apple Silicon的机器不要下Intel版本因为需要通过模拟层运行性能和兼容性都会打折反过来Intel的机器也没法直接跑arm64的镜像。系统版本方面建议macOS 12或更高版本。版本太老的话Docker Desktop新版可能跑不起来。另外提一句如果你的机器配置比较低8GB内存以下跑Docker Desktop会比较吃力因为Docker本身要占用一部分资源后面我会讲怎么限制它的资源占用。2.2 Docker Desktop 的安装细节与首启配置安装Docker最主流的方式就是去官网下载Docker Desktop安装包这里我不推荐去搞什么命令行brew安装因为桌面版自带图形化管理界面、文件共享配置、资源限制面板对新手上手最友好。下载完成后把Docker.app拖进“应用程序”文件夹然后双击启动。首次启动会弹窗请求权限输入你的Mac密码安装辅助组件。之后菜单栏右上角会出现一个小鲸鱼图标点一下能看到Docker Desktop的启动状态。等鲸鱼图标不再闪动进入“About”面板能看到版本信息说明Docker引擎已经就绪。打开终端运行下面两个命令确认环境正常docker --version docker compose version能看到类似Docker version 27.x.x和Docker Compose version v2.x.x的输出说明安装成功。如果你发现docker命令找不到大概率是Docker Desktop没启动或者PATH没配置好。Docker Desktop正常安装后会自动把docker命令写进PATH不需要手动改。2.3 镜像加速器要不要配国内网络环境拉Docker Hub镜像懂的都懂经常慢到怀疑人生。Docker Desktop里提供了一个镜像加速配置入口路径是Docker Desktop → Settings → Docker Engine然后在JSON配置里加上registry-mirrors字段。一个常见的配置长这样{ registry-mirrors: [ https://docker.mirrors.ustc.edu.cn, https://hub-mirror.c.163.com ] }配置完点“Apply Restart”Docker引擎会重启再拉镜像时就会走加速源了。不过这里要提醒一句公共加速源有时候也不稳定你本地多试几个源哪个快用哪个。如果某个加速源挂了就把registry-mirrors暂时清空直接走官方源反而更稳。这个配置属于“没配置也能用配置了更舒服”的类型。不配置的话小镜像还能忍受像php:8.3-fpm-alpine这种几百MB的镜像你可能就得泡杯茶等它下完了。3. 先想清楚再动手nginx php 架构与目录规划3.1 nginx 和 php-fpm 到底是怎么配合的很多人第一次部署nginxphp会下意识地以为需要在同一个容器里既装nginx又装php。实际上生产环境和你本地最推荐的做法都是两个独立容器各干各的。nginx本身只处理静态资源和请求转发。当nginx收到一个请求发现访问的是.php结尾的文件它自己不会解析而是通过FastCGI协议把请求扔给php-fpm处理。php-fpm是PHP的进程管理器它监听一个端口默认9000收到nginx扔过来的请求后找到对应PHP文件用PHP解释器执行把结果返回给nginxnginx再拼上HTTP响应头返回给浏览器。这个协作过程用大白话讲nginx像餐厅前台php-fpm像后厨。前台收下点菜单HTTP请求如果是现成的菜静态文件直接端给你如果是需要现做的菜PHP动态请求把单子传给后厨等后厨做好了再端出来。前厅和后厨是两个独立房间中间通过一个传菜口连接这个传菜口就是FastCGI协议。在Docker里这两个“房间”就是两个容器。它们之间不需要通过端口映射来通信因为在同一个compose网络里容器之间直接用服务名就能互相访问。比如nginx容器要访问php容器配置里写fastcgi_pass php:9000这里的php就是compose服务名Docker会自动解析成php容器的IP地址。这是初学者最容易卡住的地方。3.2 推荐目录结构与镜像选型动手写文件之前先建一个项目目录我习惯放在~/docker-nginx-php下后续所有文件都放这里。目录结构规划好后面维护起来非常舒服docker-nginx-php/ ├── docker-compose.yml ├── nginx/ │ └── conf.d/ │ └── default.conf ├── php/ │ └── Dockerfile └── src/ └── index.phpsrc目录是你项目的代码根目录。nginx和php容器都要访问这个目录所以它会被挂载到两个容器里去。这样本地改代码容器里立刻同步不用重新构建镜像。镜像选型方面我的建议是nginx用nginx:1.27-alpineAlpine版本体积小、内存占用低适合开发环境php用php:8.3-fpm-alpine自带php-fpmAlpine版本同样小巧。为什么不直接用官方默认的nginx和php镜像因为默认镜像基于Debian体积大不少而Alpine版精简了大量用不上的组件两个镜像加起来能小一半。这里刻意选比较新的版本号是为了让你拿到手就能用上较新的PHP特性和安全补丁。如果你项目有固定的PHP版本要求比如公司项目强制PHP 7.4把数字改成对应版本号就好其他部分不用动。4. 写出你的第一份 Docker Compose 编排文件4.1 compose 文件逐行解读现在开始写核心文件。第一个是docker-compose.yml我用的是Compose V2的写法不需要version字段直接声明services就行services: nginx: image: nginx:1.27-alpine container_name: nginx-web ports: - 8080:80 volumes: - ./src:/var/www/html - ./nginx/conf.d:/etc/nginx/conf.d depends_on: - php networks: - lnmp php: image: php:8.3-fpm-alpine container_name: php-fpm volumes: - ./src:/var/www/html networks: - lnmp networks: lnmp: driver: bridge逐行解释一下关键配置。ports: 8080:80的意思是宿主机8080端口映射到容器内80端口这样浏览器访问http://localhost:8080时请求会进入nginx容器。为什么用8080而非80因为macOS上80端口经常被系统自带的Apache或者其他服务占用一上来就绑80容易报错先用8080跑通再说。volumes挂载需要特别注意nginx容器挂载了./src到/var/www/html也挂载了./nginx/conf.d到/etc/nginx/conf.d。后者是nginx容器读取站点配置的地方我们把宿主机上的default.conf挂进去就不用进入容器内部改配置了改完重启一下nginx容器就能生效。depends_on: php只是告诉compose先启动php容器再启动nginx容器这是为了防一手nginx启动时php还没就绪导致第一次连接失败。4.2 nginx 站点配置fastcgi_pass 是关键接下来写nginx/conf.d/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; } }最重要的地方是fastcgi_pass php:9000。php是docker-compose.yml里的服务名前面说过同一个自定义网络里容器可以直接通过服务名互相访问。如果你不用compose、改用docker run手动起容器这个位置就得写容器的IP地址但那很不方便重启容器IP可能就变了所以这里用compose网络是省心做法。fastcgi_param SCRIPT_FILENAME这行同样容易踩坑它告诉php-fpm要执行哪个PHP文件。$document_root对应上面的root /var/www/html$fastcgi_script_name是请求的PHP文件名。如果漏掉这个参数或者路径跟php容器里的挂载路径对不上php-fpm就会报文件找不到nginx返回404。4.3 单独为 php 加一个 Dockerfilephp/Dockerfile里目前只做一件事安装PHP扩展。如果你不需要连MySQL这个文件甚至可以不用直接依赖compose里的php:8.3-fpm-alpine镜像就能跑。但一个典型的PHP项目基本都要连MySQL所以我提前把扩展装上FROM php:8.3-fpm-alpine RUN docker-php-ext-install pdo_mysql mysqli基础镜像里自带了一个docker-php-ext-install脚本专门用来编译安装PHP扩展。比如pdo_mysql是PDO连接MySQL的驱动mysqli是老式MySQL扩展按需安装。如果你还要装redis、zip这类扩展推荐先查一下对应的安装方式有些需要额外依赖库不能一句命令搞定。写完之后先别急着启动先把src/index.php建好写个探针页面验证环境?php phpinfo();这个页面会展示PHP版本、已加载的扩展、配置路径等信息。等整个环境跑起来后访问这个页面就能确认php-fpm是否正常解析了PHP文件。5. 启动、验证与第一次踩坑5.1 一键启动与环境验证来到项目目录打开终端执行docker compose up -d-d表示后台运行。第一次执行会先拉取两个镜像时间取决于网络。镜像拉完之后终端会出现两个容器的启动状态。执行docker ps就能看到两个容器都在运行状态为Up。然后打开浏览器访问http://localhost:8080如果看到phpinfo页面说明整条链路已经通了。这里可能有人会问为什么访问的是index.php而不是phpinfo因为nginx配置里index指令设置了默认查找index.php而src/index.php正好命中。再看一眼容器日志确认没有异常docker compose logs php docker compose logs nginx如果只看最后几条可以加--tail参数docker compose logs --tail20 nginx。日志是排查问题最重要的信息来源养成看日志的习惯比记住任何配置都管用。5.2 常见错误与排查技巧实录我在第一次跑这套环境时几乎把新手能踩的坑都踩了一遍。下面按出现频率整理出来现象原因解法访问页面一直转圈或报502 Bad Gatewaynginx连不上php-fpm检查default.conf里fastcgi_pass写的服务名是否正确容器是否都处于Up状态浏览器直接下载PHP文件或显示源码nginx没有把.php请求交给php-fpm检查default.conf的location匹配规则确认fastcgi_pass配置存在返回404 Not FoundSCRIPT_FILENAME路径不对default.conf里fastcgi_param SCRIPT_FILENAME要写成$document_root$fastcgi_script_name宿主机8080端口已被占用起了其他服务换端口映射比如8081:80重新docker compose up -d镜像一直拉不下来网络问题配置registry-mirrors或检查Docker引擎是否重启成功修改代码不生效没清opcache或没挂载正确确认./src映射到/var/www/html容器内没有旧的缓存残留php容器里看到代码目录为空挂载路径不对检查compose文件里相对路径确保在项目根目录执行命令这里重点说一个很多人翻车的问题改完nginx配置后一定要重启nginx容器而不是restart php容器。命令是docker compose restart nginxnginx的配置是启动时加载的你改宿主机上的default.conf不会热更新必须重启容器让它重新读配置。还有权限问题。macOS的文件系统跟Linux容器有读取权限映射如果src目录下有文件写入不了一般是因为php容器里运行的用户是www-data而宿主机目录权限限制了写入。简单粗暴的解法是在容器里给/var/www/html提权但最优雅的解法是在Dockerfile里指定运行用户或者调整目录owner这个后面单独说。6. 从能跑到好用后续必做的三件事6.1 挂载 php.ini把配置从镜像里解放出来基础镜像自带一份默认php.ini但你项目的upload_max_filesize、memory_limit、display_errors这些参数很可能跟默认值不一样。直接在容器里改php.ini容器一删就全没了所以正规做法也是挂载到宿主机。先启动一个临时容器把默认php.ini复制出来docker compose exec php php -i | grep Loaded Configuration File会显示php.ini路径比如/usr/local/etc/php/php.ini。然后在项目vendor目录下新建php/php.ini向容器里复制默认内容再按需修改。改完之后在compose文件php服务里添加挂载php: image: php:8.3-fpm-alpine container_name: php-fpm volumes: - ./src:/var/www/html - ./php/php.ini:/usr/local/etc/php/php.ini这样以后调参不用进容器而且整个项目环境配置都在git里同事clone下来直接就能用。6.2 结构调整方便调试而不是只求“能跑”很多教程到这里就结束了但我建议你再加两个小文件一个.env文件存放可变的端口号一个README.md记录启动步骤。.env文件可以这样写NGINX_PORT8080compose文件里对应改成${NGINX_PORT}:80这样不同同事的机器上如果端口冲突不需要改compose文件只改.env就行。这个小习惯特别适合团队协作场景。另外强烈建议把php-fpm的日志也挂载出来。线上排障时nginx的错误日志只能看到上游连接失败具体PHP执行了什么错误必须看php-fpm的日志。在compose文件php服务里再加一段volumes: - ./src:/var/www/html - ./php/php.ini:/usr/local/etc/php/php.ini - ./logs/php:/var/log/php然后在php容器里确认日志目录的写权限。你现在把日志挂出来早晚会用上。6.3 性能与调试顺手把 Xdebug 配上本地开发怎么能少得了调试工具。Xdebug对php-fpm来说就像一个调试代理它监听9000端口的调试请求IDE比如VS Code连上之后就能打断点、看变量。在php的Dockerfile里加一层安装逻辑RUN pecl install xdebug \ docker-php-ext-enable xdebug然后在php.ini里写上[xdebug] xdebug.mode debug xdebug.start_with_request yes xdebug.client_host host.docker.internal xdebug.client_port 9003为什么client_host要写host.docker.internal因为容器里的PHP执行时要回连宿主机上的IDE。host.docker.internal是Docker Desktop自动帮你解析到宿主机地址的固定域名容器里直接用就行前提是Docker Desktop正常安装。配好之后重启php容器IDE里设置好监听端口9003打开调试开关就能像以前本地环境一样打断点了。这一步做完你本地这套容器环境的使用体验就已经超过很多所谓“专业环境”了。6.4 清理与备份别让容器堆积成山开发过程中免不了反复构建、反复折腾。几个实用命令先记下来# 停止并删除容器不会删除镜像和挂载数据 docker compose down # 同时删除网络和匿名卷 docker compose down --volumes # 清理所有悬空镜像 docker image prune # 查看某个容器的资源占用 docker stats有一点很重要docker compose down不会删除你挂载到容器里的宿主机文件所以代码和配置都不会丢。但如果你把数据写在容器内部比如MySQL的数据文件不挂载down之后数据就没了。这个观念要建立起来容器是无状态的状态全部留在挂载目录或者外部数据卷里。有不少人开发到一半想“重置一下环境”我通常不建议直接docker system prune -a那会把所有镜像都删掉下次还得重新拉。精确一点的做法是删掉当前项目的容器和网络保留镜像按需重建这样既干净又不用重新下载镜像。我在实际维护这套环境时最深的体会是docker-compose让环境定义变成了代码这意味着你不需要记住任何“当初是怎么配置的”这种问题。所有配置都在项目目录里看着compose文件就知道整个架构长什么样。即使这台Mac哪天坏了只要项目目录还在换台机器装好Docker进去敲一句docker compose up -d环境立马原地复活。这个收益远比刚开始多花半小时学Docker来得值。