LNMP 这套组合里,Nginx 通常是第一个被装上去、也是最后一个被真正调通的那个。MySQL 只要端口能连上、账号能登进去,基本就完事了;PHP 容器跑起来能看到 phpinfo,也算阶段性胜利;唯独 Nginx,配置文件看着都对,容器也在跑,浏览器一打开就是 403、404 或者 502,日志里翻半天也看不出个所以然。这一篇就专门讲 Docker 环境下安装 nginx 这件事,从镜像怎么选、目录怎么挂、配置怎么写,到它和 PHP 容器之间那条 FastCGI 通道怎么打通,以及我在这台机器上真实踩过的几个坑,全部摊开说。适合已经跑起了 MySQL 和 PHP 容器、正准备把 Web 入口接上的朋友,也适合刚开始用容器化方式搭环境、想搞清楚每一步在干什么的新手——下面这些命令和配置都可以直接抄,但每一段为什么这么写,我会一并交代清楚。
1. Nginx 在这次容器组合里到底站在哪个位置
1.1 三个容器之间的职责边界先划清楚
用 Docker 搭 LNMP,最忌讳的一件事就是脑子里还是"一台机器装三个软件"的老模型。容器化之后,这三个东西是三个独立的运行单元,各自有独立的文件系统、独立的进程空间,它们之间唯一的联系是 Docker 网络和共享挂载的目录。所以动手装 Nginx 之前,我习惯先在纸上把边界画清楚:Nginx 容器负责监听 80 端口、接收 HTTP 请求、处理静态文件、把 .php 结尾的请求转发给 PHP 容器;PHP 容器只负责跑 php-fpm,监听 9000 端口,等 Nginx 把请求递过来,它自己不需要对外暴露任何端口;MySQL 容器只跟 PHP 容器打交道,Nginx 从头到尾都不会跟数据库说一句话。
这个边界划清楚之后,很多看起来莫名其妙的问题就有了解释。比如你在浏览器里看到一个 PHP 报错说连不上数据库,那这个请求已经顺利穿过了 Nginx 和 PHP 两层,问题只可能出在 PHP 容器到 MySQL 容器那一段,跟 Nginx 配置没有半点关系。反过来,如果浏览器返回的是 502,那说明请求到了 Nginx,但 Nginx 没能从 PHP 那边拿到有效响应,问题在两者之间的通道上,跟数据库更没关系。这种"按层定位"的思路,比对着日志一行行猜要高效得多,也是后面所有排错工作的基础。
我见过不少人把 Nginx 和 PHP 装在同一个容器里,图省事,镜像里又是 nginx 又是 php-fpm 还要装 supervisord 管进程。这种做法在小 Demo 阶段确实跑得起来,但它把两套完全不同的生命周期绑在了一块:改一行 Nginx 配置要重启整个容器,PHP 那边 OOM 了 Nginx 也跟着挂。分开之后,改配置就是改挂载文件加一次 reload,两边互不影响,升级也是各自升级各自的。
1.2 为什么不直接复用宿主机上的 Nginx
一个很自然的想法是:宿主机上本来就装着 Nginx,让它监听 80,把 .php 请求转发到 PHP 容器的 9000 端口不就完了,何必再起一个容器。技术上完全可行,但我实际用下来更倾向于把 Nginx 也放进容器,原因有三个。
第一是路径一致性。Nginx 转发 PHP 请求时,传给 php-fpm 的参数里有一个SCRIPT_FILENAME,这是 PHP 容器内部要去读的文件路径。如果 Nginx 跑在宿主机上,它看到的项目路径可能是/home/xxx/www/project,而 PHP 容器里这个目录挂载在/var/www/html,两边路径对不上,php-fpm 就会告诉你"Primary script unknown",然后返回 404。要么你在宿主机上把项目目录软链到跟容器内一模一样的路径,要么你在 Nginx 配置里做路径映射,怎么看都比直接把 Nginx 也容器化要别扭。
第二是环境一致性。宿主机上的 Nginx 版本、编译参数、模块列表是固定的,换一台机器部署,可能就不一样了。容器化之后,镜像标签写死版本,谁跑都是同一份二进制、同一份默认配置,出问题好复现。
第三是清理成本。实验性质的搭建,玩完了docker compose down一条命令全清掉,宿主机上不留任何痕迹。用宿主机的 Nginx,配置散落在/etc/nginx/conf.d,日志散落在/var/log/nginx,时间一长自己都记不清哪个文件是干什么的。
1.3 端口和网络拓扑要在动手前定死
装任何容器服务,端口规划这件事我建议写在第一次动手之前,写在备忘录里都行。因为一旦跑起来再去改,涉及的挂载、配置、防火墙规则全要跟着改,很烦。
我这次用的规划是这样的:
| 服务 | 容器内端口 | 宿主机映射 | 是否对外开放 |
|---|---|---|---|
| nginx | 80 | 8080 | 是,浏览器访问入口 |
| nginx | 443 | 8443 | 先留着,证书配好再启用 |
| php-fpm | 9000 | 不映射 | 否,只在容器网络内可达 |
| mysql | 3306 | 33060 | 仅本机 127.0.0.1,调试用 |
宿主机 80 端口之所以不用,是因为我这台机器上还跑着别的东西,80 已经被占了,用 8080 做映射,浏览器访问http://127.0.0.1:8080一样的效果。这里有个细节值得说一句:映射端口写成-p 8080:80的时候,前面的 8080 是宿主机端口,后面的 80 是容器内端口,别写反了。写成-p 80:8080的话,容器里 Nginx 监听的是 80,你在宿主机上访问 8080 是永远打不开的,这个方向搞反是新手最常见的错误之一。
网络方面,我不用默认的 bridge,而是自己建一个自定义网络。原因很简单:Docker 默认 bridge 网络里的容器之间只能用 IP 互相访问,容器重启后 IP 会变,Nginx 配置里写死的fastcgi_pass 172.17.0.5:9000下次就失效了。自定义网络支持用容器名做 DNS 解析,写fastcgi_pass php:9000就行,IP 怎么变都不影响。
docker network create --driver bridge lnmp-net docker network ls | grep lnmp这条命令执行完之后,网络就建好了。后面启动 nginx、php、mysql 三个容器的时候,都加上--network lnmp-net,它们就自动在同一个网络里,能用名字互相找到。
2. 镜像标签怎么选,以及拉取不顺时的处理顺序
2.1 alpine 版和完整版的取舍
官方 nginx 镜像的标签挺多的,latest、stable、mainline、alpine以及各种版本号组合,第一次看确实容易懵。我自己的选择规则很朴素:生产或者类生产环境用带具体版本号的 alpine 版本,本地调试图省事可以用完整版。
alpine 版本体积小,nginx:1.25-alpine拉下来压缩后大概十几兆,解压后四十兆左右,启动快,攻击面也小。代价是它基于 musl libc,如果你后面想加一些需要 glibc 的第三方模块,或者想在里面跑一些依赖系统库的调试工具,可能会遇到麻烦。完整版基于 Debian,体积大一些,但工具链齐全,apt、bash、curl都有,排查问题的时候舒服。
这两个版本的对比我列了一下:
| 镜像标签 | 解压后体积 | 适合的场景 | 需要注意的地方 |
|---|---|---|---|
nginx:1.25-alpine | 约 40MB | 长期运行、资源紧张 | musl libc,编译第三方模块不便 |
nginx:1.25 | 约 190MB | 调试期、需要临时装工具 | 体积大,启动稍慢 |
nginx:stable | 跟随稳定分支 | 想要稳定但懒得记版本号 | 标签会漂移,不推荐用于生产 |
nginx:latest | 不确定 | 不建议 | 某天拉下来可能就变了个大版本 |
latest这个标签我是真的不建议用。它今天指向 1.25,半年后可能指向 1.27,某次重新拉镜像或者换机器部署,配置文件里某个指令被废弃了,容器直接起不来,而你根本不知道是哪里变了。版本号写死,升级变成一个有意识的操作,而不是被动接受。
2.2 拉取慢、拉取失败时的排查顺序
镜像拉取这一步,不同网络环境下体验差异很大。我遇到的失败情况大概分三类,处理顺序是从外到内一层层看。
第一类是超时,卡在Pulling from library/nginx半天不动。这种情况先确认基础连通性,curl -I https://registry-1.docker.io/v2/看能不能通。如果连不上,多半是本机的网络出口有问题,跟 Docker 配置没关系。如果能通但依然很慢,那就是大文件传输的问题,考虑配置镜像加速地址,这个在系列的前几篇里应该已经配过了,/etc/docker/daemon.json里加上 registry-mirrors 然后systemctl reload docker。
第二类是拉下来了但解压报错,比如failed to register layer: no space left on device。这是磁盘满了,df -h一看便知。Docker 的默认存储目录在/var/lib/docker,如果根分区给得小,装几个镜像就撑满了。处理办法要么清掉不用的镜像docker image prune -a,要么把>docker pull nginx:1.25-alpine docker image inspect nginx:1.25-alpine --format '{{.Architecture}} {{.Os}}'
拉取成功之后,别急着run,先看一眼镜像的默认配置。docker image inspect nginx:1.25-alpine --format '{{json .Config}}'能看到默认的入口命令、暴露端口、工作目录。这些信息决定了你后面怎么覆盖启动参数。
2.3 镜像里的默认配置文件长什么样
在挂载自己的配置之前,我强烈建议先进去看一眼官方镜像自带的配置,不然你根本不知道自己覆盖掉的是什么。
docker run --rm nginx:1.25-alpine cat /etc/nginx/nginx.conf输出里能看到几个关键点:user nginx;表示 worker 进程以 nginx 用户运行;include /etc/nginx/conf.d/*.conf;在 http 块里,意思是在 conf.d 目录下所有 .conf 结尾的文件都会被当作 http 级别的配置包含进来;还有一个include /etc/nginx/mime.types;,管的是各种文件扩展名对应的 Content-Type。另外/etc/nginx/conf.d/default.conf是镜像自带的默认站点配置,监听 80,root 指向/usr/share/nginx/html。
知道这些之后再挂载,心里就有数了:如果我把自己写的default.conf挂到/etc/nginx/conf.d/default.conf,镜像自带的那个就被覆盖了;如果我挂载整个conf.d目录,那目录里的所有文件都以我的为准。这两者的区别在后面的坑里会详细讲,先记住结论就行。
3. 启动命令里每一个参数的作用
3.1 目录结构先规划好再写挂载
写docker run之前,我习惯先把项目目录在宿主机上建好。目录结构清晰,后面写挂载参数就是一比一映射,不容易搞混。
mkdir -p ~/lnmp/nginx/conf.d mkdir -p ~/lnmp/nginx/logs mkdir -p ~/lnmp/www mkdir -p ~/lnmp/php建出来长这样:
~/lnmp/ ├── nginx/ │ ├── nginx.conf # 主配置,覆盖镜像默认的 │ ├── conf.d/ │ │ └── default.conf # 站点配置 │ └── logs/ # 访问日志和错误日志 ├── php/ │ └── Dockerfile # 如果前面几篇是自己构建的 PHP 镜像 └── www/ # 项目代码,nginx 和 php 都要挂载这里最关键的一条:~/lnmp/www这个目录,Nginx 容器和 PHP 容器必须挂载到容器内完全相同的路径。我一般统一用/var/www/html。这是前面提到的SCRIPT_FILENAME问题的根源所在——Nginx 计算出来的脚本路径会原样传给 php-fpm,如果 PHP 容器里那个路径不存在,就是 404。
3.2 一条完整的启动命令逐段拆解
docker run -d \ --name nginx \ --network lnmp-net \ --restart unless-stopped \ -p 8080:80 \ -p 8443:443 \ -v ~/lnmp/nginx/nginx.conf:/etc/nginx/nginx.conf:ro \ -v ~/lnmp/nginx/conf.d:/etc/nginx/conf.d:ro \ -v ~/lnmp/nginx/logs:/var/log/nginx \ -v ~/lnmp/www:/var/www/html:ro \ -v /etc/localtime:/etc/localtime:ro \ nginx:1.25-alpine-d是后台运行,这个不解释。--name nginx这个容器名很重要,因为同一网络里的其他容器要拿它做 DNS 解析,写成fastcgi_pass nginx:9000这种就说明名字用错了,应该是 php。名字一旦定了,后面所有配置里的上游地址都得跟它对上。
--network lnmp-net接上刚才建的网桥。--restart unless-stopped表示除非手动停,否则容器挂了就自动拉起来——服务器断电重启之后,这三个容器会自己回来,不用你手动一个个start。我吃过没写这个的亏,某次机器意外重启,以为是代码问题查了半小时,最后发现容器压根没起来。
-p 8080:80和-p 8443:443是端口映射,前面已经说过方向。-v那几行是挂载,要注意:ro这个后缀,它表示容器内对该目录只有读权限。配置文件、代码目录加上:ro是个好习惯,万一容器里跑了什么奇怪的进程,也改不了宿主机上的文件。日志目录不能加:ro,因为 Nginx 要往里写。
-v /etc/localtime:/etc/localtime:ro这一行是为了让容器内的时区跟宿主机一致。不加的话,日志里的时间戳是 UTC,跟你的本地时间差八小时,排查问题时对不上时间线,非常难受。
最后是镜像名。整条命令执行完,docker ps能看到 nginx 容器状态是 Up,就说明启动成功了。如果状态是Restarting或者Exited,立刻docker logs nginx看原因,八成是配置文件写错了。
3.3 用 compose 管理会更省心
同样的东西用 docker compose 描述,可读性和可维护性都会好很多。前面几篇如果是用 compose 起的 MySQL 和 PHP,这里直接加一个 nginx 服务就行。
services: nginx: image: nginx:1.25-alpine container_name: nginx restart: unless-stopped ports: - "8080:80" - "8443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/logs:/var/log/nginx - ./www:/var/www/html:ro - /etc/localtime:/etc/localtime:ro networks: - lnmp-net depends_on: - php networks: lnmp-net: external: truedepends_on这一行值得单独说一下。它保证 compose 启动时先起 php 再起 nginx,顺序上是对的,但它不保证 php 已经准备好接受连接,只是保证容器进程起来了。这一点在后面的 502 章节会展开。另外external: true表示这个网络是外部已经存在的,compose 不会去创建它也不会在down的时候删掉它,配合前面手动docker network create的用法刚好。
4. 配置文件的拆分逻辑:主配置管骨架,站点配置管业务
4.1 nginx.conf 里只留全局性的东西
很多人一上来就把整个站点配置塞进nginx.conf,几百行堆在一起,改一次要往下翻半天。我的做法是把nginx.conf当成"骨架",只放跟具体站点无关的全局设置,站点相关的全部拆到conf.d下面。
user nginx; worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; server_tokens off; client_max_body_size 20m; include /etc/nginx/conf.d/*.conf; }逐项说一下为什么这么写。worker_processes auto让 Nginx 按 CPU 核心数自动决定 worker 数量,比手写死一个数字更适应不同机器。sendfile on开启零拷贝,静态文件传输效率会明显好一些,尤其在容器里挂载了宿主机目录的场景,这个选项的影响挺直观。
server_tokens off是把响应头里的版本号藏掉,Server: nginx/1.25.3变成Server: nginx。这不是什么高深的安全措施,但能减少一点信息暴露,加上也没成本。
client_max_body_size 20m这一行是必须提前想好的。Nginx 默认只允许 1MB 的请求体,超过就返回 413。你上传一个稍大的图片、传个 Excel,全都卡在这里。很多人排查上传失败查了半天 PHP 的upload_max_filesize,其实第一道关卡在 Nginx 这里就拦下了。这个值设多少,取决于你的业务,20m 对我这个项目够用了。注意 PHP 那边也有对应的upload_max_filesize和post_max_size,两边要匹配,Nginx 这边小的话,PHP 配多大都没用。
最后那个include /etc/nginx/conf.d/*.conf;放在 http 块末尾,把站点配置引进来。顺序上它必须在 http 块内部,如果写到外面去,Nginx 启动直接报错"server" directive is not allowed here。
4.2 default.conf 的 server 块逐行解释
站点配置我一般写在这个文件里:
server { listen 80; server_name localhost; root /var/www/html; index index.php index.html index.htm; charset utf-8; 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; } location ~ /\.ht { deny all; } access_log /var/log/nginx/default.access.log main; error_log /var/log/nginx/default.error.log warn; }listen 80是容器内监听,跟宿主机的 8080 没关系,别写成 8080。
root /var/www/html必须与挂载点、PHP 容器的挂载点三者完全一致。这三个地方只要有一个不一样,就是 404 或者"Primary script unknown"。
index index.php index.html的顺序有讲究。把 index.php 放前面,是因为这种项目通常以 PHP 为入口;如果放后面,访问目录时 Nginx 会先找 index.html,找到了就不走 PHP 了,你改的 PHP 代码怎么都不生效,这种问题查起来很折磨人。
try_files $uri $uri/ /index.php?$query_string;这一行是伪静态的核心。它的逻辑是:先看请求的 URI 对应文件存不存在,存在就直接返回;不存在就看是不是个目录,是就找目录下的索引文件;都不行就把请求交给/index.php处理,并把原始查询串带上。很多框架的路由都靠这一行工作,缺了它,除了首页以外全是 404。
location ~ \.php$这个正则块匹配所有以 .php 结尾的请求,把它们转给 FastCGI 处理。这里的~表示区分大小写的正则匹配,如果你担心有人用.PHP大小写绕过,可以写成~*。
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;这一行是最容易漏的。官方镜像里的fastcgi_params文件内容并不包含SCRIPT_FILENAME,它只有QUERY_STRING、REQUEST_METHOD这些。所以你必须自己手动加一行,否则 php-fpm 收到的SCRIPT_FILENAME是空的,它会去找一个不存在的路径,然后返回Primary script unknown,浏览器看到的就是 404。这个问题我见过太多次了,网上一搜一大片,根源就是漏了这一行。
注意这个fastcgi_param要写在include fastcgi_params;前面。因为 include 会把一堆参数塞进来,如果后面又有同名参数,后写的会覆盖前面的。虽然fastcgi_params里没有SCRIPT_FILENAME,顺序上其实无所谓,但养成"自定义参数在前、include 在后"的习惯,能避免很多隐性覆盖问题。
4.3 fastcgi_pass 里的主机名是在什么时候被解析的
fastcgi_pass php:9000;里的php是容器名,靠 Docker 内置 DNS 解析成 IP。这里有个非常关键、也非常容易踩的细节:Nginx 是在启动时解析 upstream 主机名的,不是在请求时。
也就是说,如果 nginx 容器启动的时候,php 容器还不存在或者不在同一个网络里,Nginx 会直接启动失败,日志里是host not found in upstream "php",然后容器进入重启循环。这个行为和很多人想象的"运行时才连"不一样。
这就带来一个实操上的顺序要求:必须先启动 php 容器,再启动 nginx 容器。用 compose 的depends_on可以保证顺序,但如果你是手动docker run,一定要留意。更麻烦的情况是 php 容器后来被删掉重建了(比如改了镜像),而 nginx 容器一直在跑,那 nginx 里的 DNS 缓存可能还指向旧 IP,请求就全 502 了。解决办法是重建 php 之后顺手docker restart nginx,让它重新解析一次。
如果实在不想受这个限制,有两种做法。一种是引入变量加 resolver:
resolver 127.0.0.11 valid=10s; location ~ \.php$ { set $php_upstream php:9000; fastcgi_pass $php_upstream; ... }用了变量之后,Nginx 就变成运行时解析了,配合resolver 127.0.0.11(这是 Docker 内置 DNS 的固定地址)和较短的valid时间,能适应 php 容器 IP 变化。代价是每次请求都多一次 DNS 查询,虽然本地开销很小,但配置复杂度上去了。我个人的选择是老老实实保证启动顺序,简单直接,出问题的概率反而更低。
5. 静态资源打不开时的分叉排查
5.1 403 和 404 各自指向什么问题
配置写完,docker restart nginx,浏览器访问,结果不是想要的页面。这时候别急着乱改,先看返回的状态码,它已经把问题范围缩得很小了。
| 状态码 | 最可能的原因 | 快速验证方式 |
|---|---|---|
| 403 Forbidden | 目录下没有 index 文件,且目录浏览被禁止;或文件权限不足 | 在 root 目录下放一个 index.html 试试 |
| 404 Not Found | root 路径不对;文件确实不存在;伪静态规则缺失 | docker exec nginx ls /var/www/html看文件在不在 |
| 502 Bad Gateway | php 容器没起来、端口不对、SCRIPT_FILENAME 错误 | 看 error.log 里的具体报错 |
| 504 Gateway Timeout | php 执行超时,或 fastcgi 读超时设置太短 | 看 PHP 那边是不是在跑耗时任务 |
403 最常见的原因是 root 目录下确实没有 index 文件,或者 index 里配置的文件名跟实际文件对不上。比如你配了index index.php,但目录里只有index.html,Nginx 找不到索引文件,又没开autoindex on,就只能返回 403。另一种 403 是文件权限问题:Nginx 的 worker 进程以 nginx 用户运行,如果挂载进来的文件属主是 root 且权限是 600,nginx 用户读不了,也是 403。容器里跑docker exec nginx ls -l /var/www/html就能看到属主和权限。
404 则要分两种情况。一种是文件真的不在,docker exec进去ls一下就知道了。另一种是文件在,但 Nginx 找的路径不对——这就回到那个老问题:root配置的路径和实际挂载路径是否一致。我遇到过最隐蔽的一次是末尾多了个斜杠,root /var/www/html/;和root /var/www/html;在配合location前缀匹配时行为不一样,会导致 URI 拼接出现双斜杠或者路径错位。虽然多数情况下 Nginx 会容错,但在alias场景下这个差异是致命的。用root的时候老老实实不加末尾斜杠。
5.2 代码目录的挂载一致性检查清单
前面反复强调三个路径要一致,这里给一个具体的自查流程,出问题时按顺序走一遍,基本能定位。
第一步,看宿主机上的文件在不在:ls -l ~/lnmp/www/,确认index.php或index.html确实存在。
第二步,看 nginx 容器里能不能看到:docker exec nginx ls -l /var/www/html/。看不到就是挂载参数写错了,docker inspect nginx --format '{{json .Mounts}}'能看到实际的挂载映射,对着检查。
第三步,看 php 容器里能不能看到:docker exec php ls -l /var/www/html/。这里同样要能看到文件,而且路径字符串必须跟 nginx 配置里的root完全一样。nginx 那边是/var/www/html,php 这边挂到了/var/www/html,两边对上,SCRIPT_FILENAME才能被 php-fpm 正确打开。
第四步,看 php-fpm 进程能不能读到。docker exec php ps aux | grep php-fpm看 worker 进程跑在哪个用户下,再对比文件的权限位。如果 php-fpm 是以 www-data 用户跑的,而文件是 600 且属主是 root,那一样读不了。
这四步走完,静态文件和 PHP 文件的路径问题基本都能揪出来。我习惯把这几条命令存成一个脚本,出问题直接跑一遍,比在浏览器和终端之间来回切要快。
5.3 autoindex 要不要开
调试阶段,把autoindex on;打开能救命。目录里到底有什么文件,浏览器里一眼看到,不用反复docker exec。但这个东西上线前必须关掉,它会把目录结构暴露给任何人,属于典型的信息泄露。
我自己的做法是在conf.d下单独放一个debug.conf,里面就一个autoindex on;,调试的时候 include 进来,调完把文件删掉或者注释掉。这样不会污染主配置,也不容易忘。
6. PHP 通道打不通:502 与超时的几种来源
6.1 502 的四个常见来源
502 是 Nginx 明确告诉你"我连不上上游"。按可能性从高到低排,大概这么几种。
第一种,php 容器根本没在跑,或者不在同一个网络。docker ps看 php 容器的状态,docker inspect php --format '{{json .NetworkSettings.Networks}}'看它接的是不是lnmp-net。两个容器不在同一个网络里,名字解析不了,自然连不上。这个错误在 error.log 里的表现是connect() failed (111: Connection refused) while connecting to upstream。
第二种,端口写错了。php-fpm 默认监听 9000,但如果你前面几篇里改过www.conf里的listen,比如改成listen = 127.0.0.1:9000,那它就只监听容器内的回环地址,nginx 从外面连不进来。这种情况 error.log 里同样是 connection refused,但docker exec php netstat -tlnp一看就明白:监听地址是127.0.0.1:9000还是0.0.0.0:9000。必须是后者。
第三种,SCRIPT_FILENAME 路径不对。这个在 Nginx 看来连接是成功的,但 php-fpm 返回 "Primary script unknown" 或者 "Unable to open primary script",Nginx 把它转成 502 或 404 给浏览器。error.log 里能看到 File not found 的具体路径,一眼就能判断是不是路径没对上。
第四种,SELinux 或者 AppArmor 拦了。在一些安全策略比较严的发行版上,容器挂载宿主机目录之后,php-fpm 去读文件会被安全模块拦下来,表现也是读不到文件。ausearch或者journalctl能看到相关的拒绝日志。这种情况的处理方式取决于具体策略,通常是在挂载时加上合适的标签,或者调整策略,不建议直接关掉安全模块。
6.2 超时和缓冲区参数的调整时机
504 和 502 不一样,504 说明连上了,但是等太久没等到响应。默认配置下,Nginx 等 FastCGI 的读超时是 60 秒,PHP 那边的max_execution_time默认是 30 秒,两边其实不太匹配。
location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_connect_timeout 10s; fastcgi_send_timeout 60s; fastcgi_read_timeout 120s; fastcgi_buffer_size 32k; fastcgi_buffers 8 32k; }fastcgi_connect_timeout我设成 10 秒,意思是连不上就别等了,早点报错比挂着强。fastcgi_read_timeout设成 120 秒,是因为项目里有一些导出报表的操作确实慢,PHP 那边max_execution_time也调到了 120,两边对齐。如果 Nginx 这边是 60、PHP 那边是 120,那实际上 60 秒就断了,PHP 还在傻跑,白耗资源。
缓冲区那两个参数是给响应头用的。如果后端返回的响应头比较大(比如带了一堆 Cookie 或者很长的 Location),默认的 4k 缓冲区装不下,Nginx 会报upstream sent too big header,然后 502。把fastcgi_buffer_size调到 32k,fastcgi_buffers调成 8 个 32k,基本能覆盖绝大多数情况。这个坑我踩过一次,登录接口莫名其妙 502,查了两个小时,最后发现是登录成功后 Set-Cookie 太多,响应头超过 4k 被截断了。
6.3 一个容易被忽略的点:容器内 hosts 与 DNS
除了 fastcgi_pass 的主机名解析,还有一个地方会用到 DNS——如果 PHP 代码里要访问外部接口,或者要连 MySQL,它也是在容器网络里做域名解析。MySQL 容器名能不能被解析,取决于它们是不是在同一个网络。这一点经常被人忽略,尤其是在 PHP 连接数据库失败的时候,第一反应是查账号密码,实际上可能是网络就不通。
排查方法很直接:docker exec php ping -c 2 mysql,能通就说明网络层没问题。ping 不通但docker exec php nslookup mysql能解析出 IP,那就是 ICMP 被拦了,不影响 TCP;两个都不行,那就是网络没接上,回到前面检查--network参数。
7. 改完配置之后怎么验证,以及几种典型失效情形
7.1 nginx -t 是唯一可靠的校验手段
每次改完配置文件,先nginx -t,再 reload。这个顺序不能反,也不能跳过。
docker exec nginx nginx -t # nginx: the configuration file /etc/nginx/nginx.conf syntax is ok # nginx: configuration file /etc/nginx/nginx.conf test is successful docker exec nginx nginx -s reloadnginx -t会做两件事:语法检查,以及尝试打开配置里引用的所有文件(比如 mime.types、日志文件、include 的 conf.d 文件)。两个都通过才输出 successful。如果语法有问题,它会明确告诉你哪个文件哪一行,比如unexpected "}" in /etc/nginx/conf.d/default.conf:28,照着改就行。
我见过有人改完配置直接docker restart nginx,结果配置里有个语法错误,容器起不来,网站直接全挂。先-t的话,检查不通过容器还在正常跑着旧配置,你还有时间从容修改。这个习惯能救命的。
nginx -s reload是平滑重载。它会让 master 进程读取新配置、启动新的 worker、然后优雅地关掉旧 worker,正在处理的请求不会中断。这个比 restart 温和得多,容器重启会断开所有连接,reload 不会。
7.2 reload 不生效的几种真实原因
改完配置 reload 了,刷新浏览器,发现还是老样子。这种情况我遇到过不止一次,原因大致这么几类。
第一类,改错文件了。你以为改的是挂载进容器的那个default.conf,实际上编辑器打开的是一个备份文件或者另一个路径下的同名文件。验证方式:docker exec nginx cat /etc/nginx/conf.d/default.conf,看容器里那份到底是什么内容。这个办法最笨也最有效,能立刻排除掉"改错地方"的可能性。
第二类,浏览器缓存。尤其是改静态资源(CSS、JS)的时候,浏览器缓存了旧版本,你怎么改都看不到变化。强制刷新(Ctrl+Shift+R)或者在开发者工具里勾上 Disable cache。也可以临时在 URL 后面加个随机参数试试。
第三类,容器内还有另一个配置文件在起作用。比如你挂载了整个conf.d目录,目录里除了default.conf还有一个之前的备份default.conf.bak。虽然.bak不匹配*.conf的通配,但如果你把它改名成了.conf结尾,它就会被 include 进来,两个 server 块都监听 80,谁生效取决于加载顺序,非常混乱。检查方式:docker exec nginx ls /etc/nginx/conf.d/,把这个目录里的文件控制在最少的必要数量。
第四类,reload 信号发出去了但 Nginx 没收到。nginx -s reload依赖 pid 文件,如果 pid 文件路径配置不对或者容器内进程树有问题,信号可能根本没送到 master 进程。稳妥的做法是用docker exec nginx nginx -t && docker exec nginx nginx -s reload,然后docker logs --tail 20 nginx看有没有重载日志。如果有signal process started之类的输出,说明信号送到了。
7.3 伪静态、gzip、缓存的配置位置要分清
这三个东西经常被混着写,导致要么不生效,要么出现奇怪的行为。
伪静态(try_files)是 location 级别的,写在location /里。gzip 是 http 级别的,写在nginx.conf的 http 块里。静态资源缓存(expires)是 location 级别的,单独开一个 location 匹配静态文件后缀。
# 放在 nginx.conf 的 http 块内 gzip on; gzip_min_length 1k; gzip_comp_level 4; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_vary on;# 放在 default.conf 的 server 块内 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 30d; add_header Cache-Control "public, max-age=2592000"; access_log off; }gzip_types里不要加图片和视频,它们本身已经压缩过了,再压一遍纯属浪费 CPU,还可能让文件变大。gzip_comp_level不要设太高的值,4 左右的压缩率已经不错,再往上收益递减而 CPU 消耗陡增。gzip_vary on是给中间层缓存用的,让它知道同一个 URL 可能返回压缩和非压缩两种内容。
静态资源缓存的 location 要放在location ~ \.php$前面还是后面?正则 location 是按配置文件里的书写顺序匹配的,先匹配到哪个用哪个。静态资源的后缀不会命中\.php$,所以顺序上其实不冲突,但为了可读性我一般把静态资源放前面。
8. 我在这台机器上真实踩过的几个坑
8.1 挂载整个 conf.d 目录把默认页面搞没了
第一次搭的时候,我把~/lnmp/nginx/conf.d挂到了/etc/nginx/conf.d,然后启动了容器。当时那个目录还是空的,因为我打算先把容器跑起来再写配置。结果访问 8080 端口,出来的是 Nginx 的默认欢迎页——这本来也没错,因为镜像自带的default.conf还在。
问题出在后面。我写完自己的default.conf放进目录,reload,页面变成了 404。查了半天才发现:挂载空目录的那一刻,镜像里原有的/etc/nginx/conf.d/default.conf就被目录挂载覆盖掉了,容器里那个位置是空的。我的新配置生效了,但 root 目录指到了/var/www/html,而那个目录里当时什么文件都没有,自然是 404。
这个坑的本质是:目录挂载是"覆盖"而不是"合并"。宿主机上目录是空的,容器里对应位置原有的一切就都看不见了。理解了这一点,后面的行为就都能推导出来:你现在往宿主机目录里放什么,容器里就只有什么。
所以正确的顺序是:先写好配置文件,再启动容器挂载。或者先启动一个临时容器把默认配置拷出来,改完再正式起。拷贝的命令是:
docker run --rm nginx:1.25-alpine cat /etc/nginx/conf.d/default.conf > ~/lnmp/nginx/conf.d/default.conf8.2 php 容器重建之后 nginx 全站 502
前面提过 DNS 解析的时机问题,这里说个具体的经过。某次我改了 PHP 镜像,重新构建之后docker compose up -d php,容器重建了,IP 变了。网站瞬间全站 502。
当时第一反应是 PHP 挂了,去看docker ps,php 容器明明 Up 着。docker exec php php-fpm -t也没问题。后来看 nginx 的 error.log 才反应过来:connect() failed while connecting to upstream, upstream: "http://172.18.0.4:9000"——这个 IP 是 php 容器重建之前的旧 IP,新容器的 IP 已经变成.5了。nginx 在启动时把php解析成了.4并缓存下来,php 容器重建了它也不知道。
处理办法就是docker restart nginx,让它重新解析一次。这件事之后我把 compose 的启动顺序改成了先 php 后 nginx,并且在改动 php 容器的操作之后,习惯性加一条 nginx 重启。虽然有点笨,但比全站挂掉强。
如果不想每次都重启 nginx,前面说的resolver加变量方案是正解,代价是配置复杂一点。这里的取舍就看你的环境变更频率了,我这种改一次跑很久的场景,重启更省事。
8.3 日志目录的属主问题与磁盘占满
最后一个坑是日志。挂载~/lnmp/nginx/logs到/var/log/nginx之后,容器启动,日志写不进去,error.log 里报open() "/var/log/nginx/error.log" failed (13: Permission denied)。
原因是宿主机的logs目录是我用普通用户创建的,属主是当前用户,而容器里的 nginx worker 进程以 nginx 用户(通常 UID 101)运行,对那个目录没有写权限。解决办法要么把宿主机目录的权限放宽一点(chmod 777虽然能解决但不推荐),要么把目录属主改成容器内 nginx 用户的 UID:
sudo chown -R 101:101 ~/lnmp/nginx/logsUID 101 是官方 alpine 镜像里 nginx 用户的固定 ID,可以先docker exec nginx id nginx确认一下。改完之后日志就能正常写了。
另一个更麻烦的情况是日志一直写,磁盘满了。Nginx 默认不切割日志,访问量上来之后access.log会一直涨。容器环境里常规的 logrotate 用不上,我的做法是在nginx.conf里把访问日志格式简化,同时配合 Docker 的日志驱动限制容器 stdout 的大小,另外加一条定时任务定期清空或者归档日志文件。测试环境更简单粗暴,直接truncate -s 0清空,但要注意 Nginx 持有的是文件句柄,清空之后新写入会从文件开头继续,不会错位,这个跟rm掉文件的行为不一样——rm掉之后 Nginx 还在往那个已经不存在的 inode 写,磁盘空间不会释放,必须 reload 才能重新打开。
# 清空而不删除,磁盘空间立即释放,Nginx 无需 reload truncate -s 0 ~/lnmp/nginx/logs/access.log最后再分享一个小技巧:给 nginx 容器加一个 healthcheck,用wget或者curl打一下本地 80 端口,失败就标记为 unhealthy。配合 compose 的depends_on的condition: service_healthy,能让上层服务在 nginx 真正可用之后再启动。这个在自动化部署里挺有用,省得服务起来了但入口还没准备好。配置大概是这样:
healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] interval: 30s timeout: 5s retries: 3 start_period: 10sstart_period这个参数是给容器启动留的缓冲时间,在这段时间内的检查失败不计入重试次数,避免容器刚起来还在初始化就被判定为不健康。设成 10 秒对我这个场景够用,如果你的项目启动时要加载很多东西,适当调大一点。