前几天帮研发组把一套老旧的PHP项目从本机迁移到Docker容器里,折腾了半天,踩了不少坑。这篇文章就围绕docker部署php和nginx,把内网绑定命令讲透,同时给出完整的Nginx配置、Docker Compose编排和常见问题排查。内容适用的场景是:你有一台内网服务器,或者一台办公电脑,希望让局域网内的同事通过浏览器访问到PHP站点,但又不想让服务暴露到所有网卡。适合刚接触容器化的PHP开发,也适合运维想快速搭一套隔离环境。
1. 为什么用Docker部署PHP和Nginx,还要执着于内网绑定
1.1 这组技术栈到底解决什么问题
先捋一下三个角色的关系。Nginx是一个高性能Web服务器,擅长处理静态文件、HTTP路由和反向代理;PHP本身不是常驻服务,得靠PHP-FPM这个进程管理器去处理PHP脚本请求。两者之间通过FastCGI协议通信。传统安装方式是把Nginx和PHP-FPM都装在同一台机器上,Nginx收到以.php结尾的请求后,通过127.0.0.1:9000或Unix Socket转发给PHP-FPM。
用Docker之后,这两个进程被拆到不同容器里,通过容器网络互相访问。因为容器是隔离的,你可以给A项目用PHP 7.4,给B项目用PHP 8.3,互不干扰。同时整个环境可以用docker-compose.yml描述成代码,放到任何一台装了Docker的机器上就能一键启动,不再需要手动编译PHP扩展、调整Nginx配置。
那内网绑定命令又是什么?简单说,就是在启动容器或者配置Nginx时,明确指定服务监听哪个IP地址的哪个端口。比如-p 192.168.31.200:80:80,意思是只有访问宿主机192.168.31.200这个内网IP的80端口,流量才会进入Nginx容器。如果写成-p 80:80,Docker默认绑定的是0.0.0.0:80,所有网卡上的80端口都会暴露。在有公网IP或虚拟网卡的机器上,后者意味着外部设备也可能扫描到这个端口,安全隐患很大。
1.2 为什么不直接用集成环境或宿主机安装
很多人第一反应是用XAMPP、宝塔面板或者直接apt install nginx php。这些方案确实快,但有几个问题:第一,版本锁死。XAMPP自带PHP版本固定,项目如果要求7.2,你很难在同一台机器上跑多个版本。第二,环境依赖宿主机,卸载或升级容易把系统搞乱。第三,配置不透明,别人拿到你的项目,不知道用了哪些依赖和参数。
Docker化之后,每个项目一个目录,里面有一个docker-compose.yml,明确写着用哪个镜像、映射哪个端口、挂载哪些目录、设置什么环境变量。团队协作时,同事只需要把项目目录拷过去,docker-compose up -d就完事了。
再说安全层面。宿主机直接装Nginx,默认就监听80端口,大家往往不会注意它到底绑定在哪个网卡上。Docker使用端口映射时,很多人习惯只写端口不写IP,结果变成了所有网卡都监听。内网绑定命令其实就是一种“最小暴露”习惯——只让内网访问,不给其他接口留机会。
2. 环境准备:选对镜像,装好Docker
2.1 先装好Docker:不同系统的关键坑
Windows用户装Docker Desktop,最典型的坑就是启动时提示virtualization support not detected。这通常不是Docker的问题,而是底层虚拟化没开启。先去BIOS把Intel VT-x或AMD SVM打开,然后在Windows功能里勾选“适用于Linux的Windows子系统”和“虚拟机平台”,装好后在终端执行wsl --update。如果之前装了VMware,可能会与Docker Desktop冲突,因为二者都依赖虚拟化层。我一般建议如果你的主要工作负载在Docker,就别同时跑VMware,或者把VMware的网络模式改成仅主机。
Linux下安装相对直接。Ubuntu/Debian用官方源装一套docker-ce和compose插件:
sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin sudo systemctl enable --now docker装完把当前用户加入docker组,不然每次都要sudo:
sudo usermod -aG docker $USER重新登录后生效。macOS用户下载Docker Desktop安装即可,唯一建议是把内存调到4GB以上,因为后面还要跑PHP和Nginx以及可能存在的MySQL,内存太小容器重启会很频繁。
2.2 镜像选型:为什么是php-fpm和nginx-alpine
PHP官方镜像有很多标签,这里推荐php:8.2-fpm-alpine或者你项目需要的具体版本。不要选php:8.2-apache,因为我们用Nginx作为Web服务器,只需要FPM进程,不需要Apache。alpine后缀代表基于Alpine Linux,体积很小,适合作为基础镜像。缺点是有些PHP扩展需要用docker-php-ext-install编译安装,但官方镜像已经集成了这个工具,并不复杂。
Nginx官方镜像我一般选nginx:stable-alpine。稳定版本加上Alpine基础,运行时占用资源很少。标签不要用latest,它不稳定,每次拉取可能产生行为差异。生产或内部公用环境,固定到一个明确的版本号,比如nginx:1.26.2-alpine。
如果你不确定PHP需要哪些扩展,建议先用基础镜像跑起来,后面需要哪个扩展再写Dockerfile增加,不要一开始就装一堆,镜像会越来越臃肿。一个典型的Dockerfile示例如下:
FROM php:8.2-fpm-alpine RUN docker-php-ext-install pdo_mysql mysqli exif这样生成的镜像只多出你需要的扩展。
3. 内网绑定命令:从端口映射到Nginx监听
3.1 docker run的-p参数详解:宿主机IP:端口:容器端口
这一节是整个部署的核心,搞懂-p的语法,内网绑定就理解了一半。
Docker端口映射的写法是-p [宿主机IP]:[宿主机端口]:[容器端口],其中宿主机IP可以省略,省略后默认是0.0.0.0。我列一张表对比一下:
| docker run参数 | 实际监听 | 谁能访问 |
|---|---|---|
-p 80:80 | 0.0.0.0:80 | 所有能到宿主机网卡的设备 |
-p 127.0.0.1:8088:80 | 127.0.0.1:8088 | 只有本机能访问 |
-p 192.168.31.200:80:80 | 192.168.31.200:80 | 只有内网同网段设备可访问 |
注意,如果宿主机有多个网卡,比如一个局域网网卡、一个虚拟机的虚拟网卡、一个Docker网桥,0.0.0.0会把所有网卡的对应端口都暴露。绑定具体内网IP后,Docker会在iptables里生成精确的DNAT规则,只有目标IP等于这个内网IP的流量才会转发到容器端口。换句话说,其他网卡上的同样端口是没人监听的。
一个典型的内网绑定启动命令:
docker run -d \ --name nginx \ -p 192.168.31.200:80:80 \ -v /data/www:/usr/share/nginx/html \ nginx:stable-alpine这样运行之后,内网同事访问http://192.168.31.200就能看到Nginx页面。如果直接写-p 80:80,在同一台拥有多网卡的服务器上,风险面会大很多。
3.2 Nginx配置里的listen绑定
Docker端口映射解决了“宿主机哪个端口转给容器”的问题,Nginx自己还可以再绑定一次IP。如果你直接使用Nginx的host网络模式,或者容器内Nginx监听的是宿主机某个固定IP,就要在配置里写清楚:
server { listen 192.168.31.200:80; server_name myapp.local; 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; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }listen 192.168.31.200:80;的意思非常明确:Nginx只接收目标IP为192.168.31.200的80端口请求。如果这个IP不是宿主机上的IP,Nginx启动时会报错。所以这个配置里的IP必须提前确认好,最快的方式是ip addr(Linux)或ipconfig(Windows)查一下本机内网地址。
结合Docker端口映射,通常不需要在Nginx里再绑一遍IP,因为流量已经被Docker精确转发到容器了。但在host网络模式下,Nginx直接占用宿主机端口,这时listen绑定是唯一的保护手段。我自己的习惯是:大多数场景用Docker映射做内网绑定,少数需要跨容器共享配置时再在Nginx层面加一道保险,两层绑定会让人更放心。
3.3 用docker-compose串联起来,内网绑定更清晰
虽然docker run也可以,但PHP容器和Nginx容器需要通信,还要挂载多个目录,用Compose管理更清晰。下面是一个最小可用的docker-compose.yml:
version: '3.8' services: php: image: php:8.2-fpm-alpine container_name: php-fpm volumes: - ./www:/var/www/html networks: - appnet nginx: image: nginx:stable-alpine container_name: nginx ports: - "192.168.31.200:80:80" volumes: - ./www:/usr/share/nginx/html - ./nginx/default.conf:/etc/nginx/conf.d/default.conf depends_on: - php networks: - appnet networks: appnet: driver: bridge这里的重点,一是ports字段写法与docker run -p完全一致,"192.168.31.200:80:80"就是内网绑定命令在Compose里的体现。二是PHP和Nginx都被放进了同一个自定义网络appnet,在这个网络里,Nginx可以直接用服务名php去访问容器,不需要关心IP是否变化。
如果你重启了容器,容器的IP可能会变,但服务名php始终指向那个PHP容器。这比手动写容器IP要可靠得多。
3.4 验证端口绑定是否生效
启动后先看端口映射是否成功:
docker ps --format "table {{.Names}}\t{{.Ports}}"输出里应该看到类似192.168.31.200:80->80/tcp。如果看到的只有80/tcp,说明端口没有绑定到具体IP。再在宿主机上执行netstat -tlnp | grep :80,观察监听地址是192.168.31.200:80还是0.0.0.0:80。
在另一台内网机器上,用客户端工具访问:
curl -I http://192.168.31.200如果返回HTTP/1.1 200,说明部署成功。如果长时间连接不上,先执行telnet 192.168.31.200 80看端口通不通,不通的话直接跳到第五节排查。
4. 实操全过程:从目录到跑起来
4.1 构建一套可运行的项目目录结构
我建议每个项目保留一套独立目录,不要把所有网站的根目录揉在一起。一个常见结构长这样:
myapp/ ├── docker-compose.yml ├── nginx/ │ └── default.conf └── www/ ├── index.php └── test.phpdocker-compose.yml负责编排容器,nginx/default.conf是Nginx站点配置,www就是网页根目录。为什么要这样分?因为把配置文件和数据分开,迁移时只需要复制整个myapp目录,到新机器上直接启动即可。如果数据和配置文件混在系统目录里,很容易漏掉某个关键配置。
4.2 编写PHP页面和Nginx配置
在www/index.php里写最简单的探针文件,验证PHP-FPM是否正常:
<?php phpinfo();nginx/default.conf内容如下,注意注释标出了每个关键节点:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.php index.html; # 静态文件直接找磁盘路径 location / { try_files $uri $uri/ /index.php?$query_string; } # PHP请求交给FastCGI处理 location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }这里有两个容易踩坑的点。第一,fastcgi_pass后面跟的是php:9000,不是127.0.0.1:9000。因为在Docker Compose网络里,服务名php就是容器的主机名,Nginx容器会解析它并连接到PHP-FPM。第二,SCRIPT_FILENAME必须用$document_root$fastcgi_script_name,如果你把root改成了其他目录,这里会连带着生效,应该保持一致。
4.3 启动容器并用其他机器验证内网访问
在myapp目录下执行:
docker-compose up -d第一次会拉取镜像,等一两分钟。接着查看状态:
docker-compose ps两个容器都应该是Up状态。如果某个容器退出了,用docker-compose logs php或docker-compose logs nginx查看日志。
验证内网访问,最简单的方式是打开另一台和设备同网段的机器浏览器,输入http://192.168.31.200,能看到phpinfo()页面就说明整个链路通了。如果你写的是真实项目,建议先把框架入口文件放到www目录,再调整Nginx的try_files规则。
5. 常见问题与排查技巧实录
5.1 内网其他机器无法访问,先查这三层
这个问题出现频率最高。我按顺序梳理一下排查路径:
| 表现 | 可能原因 | 解决办法 |
|---|---|---|
| 内网机器ping不通宿主机 | 不在同一网段,或宿主机防火墙禁ping | 先确认两台机器的IP前三位一致,再检查防火墙 |
| 能ping通,但端口不通 | 宿主机防火墙拦截80端口 | Linux开放80端口:firewall-cmd --add-port=80/tcp;Windows增加入站规则 |
| 端口通,但页面打不开 | Nginx配置错误或容器没起来 | 查看容器状态和日志 |
有一类很隐蔽的问题是端口占用。你在宿主机上本来就跑着一个Nginx或Apache,Docker映射80端口不会报错,因为Docker只是创建DNAT规则,但外面访问时发现返回的不是Docker容器的页面,而是宿主机旧服务的内容。排查方式是用netstat -tlnp | grep :80看谁在监听的端口。遇到这种情况,把宿主机上的旧服务停掉,或者把Docker映射改成其他端口,比如-p 8080:80绕过冲突。
5.2 PHP页面502 Bad Gateway,多半是fastcgi_pass错了
页面能打开静态内容,但所有.php请求都返回502,说明Nginx找到了,但没有连上PHP-FPM。最常见的错误就是把fastcgi_pass写成了127.0.0.1:9000。如果你用了docker-compose且两个容器在不同的容器中,Nginx容器里的127.0.0.1指向的是它自己,不是PHP容器。
正确做法是在Compose网络里使用服务名php:9000。如果你就是用docker run方式启动的PHP容器,可以先拿到容器IP:
docker inspect php-fpm | grep IPAddress但这样不持久,容器重启IP可能变。我更推荐的做法是创建一个docker network,把两个容器都连进去:
docker network create mynet docker run --network mynet --name php-fpm -d php:8.2-fpm-alpine docker run --network mynet --name nginx -v ... -d nginx:stable-alpine然后Nginx就能用php-fpm:9000解析到那个PHP容器。
另一个可能原因是PHP容器里的FPM没监听TCP端口而改成了Unix Socket。部分基础镜像默认监听9000 TCP,但如果你自定义过配置,要确保php-fpm.conf里的listen还是9000,而不是/var/run/php-fpm.sock。因为另一个容器无法直接访问宿主机上的socket文件,除非做了特殊挂载。
5.3 静态文件403,基本都是路径和权限问题
访问http://192.168.31.200/index.html没问题,但访问根目录时返回403,通常有三个原因。一是Nginx配置的root路径和实际挂载路径不一致,比如容器里挂载到/usr/share/nginx/html,但配置写成了/var/www/html。二是宿主机目录权限不够,Nginx容器里的进程以www-data用户运行,如果父目录权限是700,宿主机都没有给其他用户读权限,容器自然读不了。解决办法是chmod -R 755 www,并把文件权限设为644。三是index指令没有包含index.php,如果你没有静态首页文件,Nginx默认找不到可列出的目录,就会403,增加index index.php;即可。
如果是开发环境,给挂载目录chmod -R 777 www能快速解决,但不建议照搬到生产环境。
5.4 多站点自定义域名,内网开发的高频需求
很多人问怎么在同一台宿主机上用Docker部署多个PHP项目,还想通过不同域名访问。核心思路是让Nginx容器暴露一个端口,在容器里配置多个server块,每个server块对应一个server_name。
server { listen 80; server_name project1.local; root /usr/share/nginx/html/project1; index index.php; location ~ \.php$ { fastcgi_pass php1:9000; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } } server { listen 80; server_name project2.local; root /usr/share/nginx/html/project2; index index.php; location ~ \.php$ { fastcgi_pass php2:9000; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }然后在内网自己的笔记本或办公机上修改hosts文件,添加两行:
192.168.31.200 project1.local 192.168.31.200 project2.local这样访问http://project1.local就能进入第一个项目,http://project2.local进入第二个。注意需要为每个项目准备一个PHP-FPM容器,或者把所有PHP项目集中到一个FPM里,通过server_name区分脚本路径。
如果你是在虚拟机里跑Docker,同时希望宿主机之外的内网机器访问到站点,需要把虚拟机的网络模式从NAT改为桥接,否则虚拟机只有一个宿主机能访问的虚拟IP,外网设备无法直接连接。这也是热词里“本地+虚拟机 多端口nginx 开发环境多站点自定义域名配置”和“docker网络不通”最常见的根源之一。
5.5 Docker Desktop启动失败:常见环境问题速查
Windows下Docker Desktop无法启动,报virtualization support not detected,前面2.1已经提到过。补充几个我能想到的细节:如果你的Windows功能里同时启用了旧版Hyper-V和WSL2,Docker Desktop可能会因为内核冲突而失败,这时可以到“启用或关闭Windows功能”里把“Hyper-V”暂时关掉,仅保留“虚拟机平台”和“适用于Linux的Windows子系统”。另外,在BIOS里确认虚拟化开启后,还要检查主板厂商的某些安全功能是否把虚拟化锁定了。做完这些,重启系统再试。
Mac环境相对少,但如果你遇到Docker Desktop一直卡在starting,可能是资源不够,到Preferences里调大内存和CPU。
6. 进阶:把内网绑定扩展到完整环境
6.1 MySQL和Redis:只暴露该暴露的
一个真实的PHP项目基本都要用到MySQL和Redis。在docker-compose里加上MySQL容器之后,要不要给它做内网绑定?我的建议是:如果团队同事需要直接用Navicat连到数据库,可以绑定到内网IP的3306端口;如果只有PHP容器需要访问数据库,那完全不要映射端口,PHP在内部网络里用服务名mysql:3306连接即可。
mysql: image: mysql:8.0 container_name: mysql environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: myapp volumes: - ./mysql-data:/var/lib/mysql ports: - "192.168.31.200:3306:3306" networks: - appnet这样MySQL既被内网绑定到了3306,又加入了appnet网络。php服务可以直接使用mysql作为主机名,不需要知道宿主机的内网IP。Redis同理,除非要单独调试Redis,否则连端口映射都不必写。
只暴露“需要被外部访问”的服务,是Docker网络的一个基本安全原则。内网绑定命令不是把所有服务都绑一遍,而是有选择地绑定。
6.2 Nginx反向代理和SSL证书,注意reload
内网环境经常用一台Nginx容器做反向代理,把不同域名转发到同一台宿主机上不同端口的服务。示例配置:
server { listen 80; server_name api.local; location / { proxy_pass http://192.168.31.200:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }如果应用到需要对HTTPS的场景,把listen 80;改为listen 443 ssl;,并挂载证书文件:
server { listen 443 ssl; server_name secure.local; ssl_certificate /etc/nginx/cert/server.crt; ssl_certificate_key /etc/nginx/cert/server.key; location / { proxy_pass http://192.168.31.200:8080; } }很多人替换了证书文件后,页面仍然显示旧证书,这是因为Nginx配置没重载。需要执行:
docker exec nginx nginx -s reload只改文件不reload,Nginx不会自动感知证书变化。还有一个容易忽略的坑:证书文件挂载进容器后,权限必须是644,目录权限不能过严,否则Nginx读取时会报错,甚至容器启动直接失败。
6.3 环境迁移和备份
Docker部署的另一个好处是迁移容易。你只需要把项目目录(包含docker-compose.yml、nginx配置、www源码以及MySQL的数据目录)打包,到新机器上安装Docker,然后docker-compose up -d,整个环境就能运行起来。
需要注意,MySQL、Redis以及用户上传的图片都属于数据文件,必须挂在宿主机目录并纳入备份。而PHP和Nginx的容器本身是无状态的,删掉重建都不影响业务。每次修改docker-compose.yml时,最好执行:
docker-compose down && docker-compose up -d而不是用docker-compose restart,因为down才会删除旧网络设置,让新增的端口映射和网络配置生效。
我个人的体会是,docker部署php和nginx,内网绑定命令只解决了最外层暴露的问题,真正的隔离要靠Docker网络和防火墙配合。别在配置里写死IP,除非你的内网IP确实一成不变;你可以把IP抽到.env文件里,这样换环境时改一处就好。另外,Nginx和PHP容器重启后IP可能变化,所以尽量用Compose的service name,而不是容器的IP地址。踩过坑之后,我现在会把所有项目的配置都做成模板,固定一套目录结构,新项目复制出来只改域名和内网IP,能省下不少时间。