Nginx 本地入口与反向代理工具手册
前端最初直连 http://127.0.0.1:3000,通常感觉不到入口层的价值。等同一个页面需要调用多个服务、OAuth 回调要求固定域名、Secure Cookie 要求 HTTPS、WebSocket 需要协议升级时,端口和路径开始侵入每一份环境配置。Nginx 可以把这些变化收敛到 app.localhost,但也会新增一层故障:请求可能命中错误的 server 或 location,路径可能被多剥离一段,来源地址可能被伪造,上游失败也可能被误判成应用错误。
下面使用三个可回显请求信息的后端和一个 Nginx 入口构造持续演进的本地联调现场。每增加一项能力,都要回答四个问题:执行了什么、应该看到什么、失败证据在哪里、修复后怎样用同一请求复验。
先把开发现场固定下来
Nginx 下载页会同时列出 mainline 与 stable 分支。实验锁定当前 stable 补丁版本对应的 nginx:1.30.3-alpine 镜像,避免 latest、stable 或 stable-alpine 在下次拉取时悄悄改变行为。团队交付还应记录 docker image inspect nginx:1.30.3-alpine 得到的镜像摘要,并在升级标签或摘要后重新核对官方 tag、基础镜像与变更记录,再执行路由、TLS、限流、缓存、灰度和回滚测试。版本变化可从 Nginx 下载页 和 Docker Hub 标签页确认。
在空目录中建立以下文件:
nginx-entry-lab/
compose.yaml
backend/
default.conf.template
websocket/
Dockerfile
server.mjs
nginx/
nginx.conf
certs/
app.localhost.pem
app.localhost-key.pemPowerShell 可以先创建目录:
New-Item -ItemType Directory -Force .\nginx-entry-lab\backend | Out-Null
New-Item -ItemType Directory -Force .\nginx-entry-lab\websocket | Out-Null
New-Item -ItemType Directory -Force .\nginx-entry-lab\nginx\certs | Out-Null
Set-Location .\nginx-entry-lab后端使用 Nginx 官方镜像自带的模板展开能力。NGINX_ENVSUBST_FILTER 限定只替换实例与版本变量,避免把 $host、$uri 等 Nginx 运行时变量提前清空。将下面内容保存为 backend/default.conf.template:
server {
listen 80;
server_name _;
location = /health {
default_type application/json;
return 200 '{"instance":"${INSTANCE_ID}","version":"${APP_VERSION}","host":"$host","path":"$uri","xff":"$http_x_forwarded_for","proto":"$http_x_forwarded_proto","request_id":"$http_x_request_id"}';
}
location = /set-cookie {
add_header Set-Cookie "lab_session=${INSTANCE_ID}; Path=/; HttpOnly; SameSite=Lax";
default_type application/json;
return 200 '{"instance":"${INSTANCE_ID}","path":"$uri"}';
}
}这个回显服务仅存在于隔离的联调网络,不发布宿主机端口。它让路径、转发头、实例版本和 request ID 都变成响应证据,不应照搬到真实业务接口。
WebSocket 需要真实的升级握手和消息往返,不能用普通 HTTP 回显代替。将下面的服务保存为 websocket/server.mjs:
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 9000, maxPayload: 64 * 1024 });
wss.on("connection", (socket) => {
socket.send("connected");
socket.on("message", (message) => socket.send(message));
});
console.log("websocket echo listening on :9000");websocket/Dockerfile 锁定 Node.js 镜像和 ws 依赖,不依赖开发机全局包:
FROM node:22.22.0-alpine
WORKDIR /app
RUN npm init -y && npm install --omit=dev --save-exact ws@8.21.0
COPY server.mjs ./server.mjs
CMD ["node", "server.mjs"]compose.yaml 定义两个稳定版实例、一个候选版实例、一个 WebSocket 服务和一个网关:
name: nginx-entry-lab
x-backend: &backend
image: nginx:1.30.3-alpine
volumes:
- ./backend/default.conf.template:/etc/nginx/templates/default.conf.template:ro
networks: [gateway]
healthcheck:
test: ["CMD-SHELL", "nginx -t >/dev/null 2>&1 && test -s /var/run/nginx.pid"]
interval: 3s
timeout: 2s
retries: 10
services:
stable_a:
<<: *backend
environment:
NGINX_ENVSUBST_FILTER: "INSTANCE_ID|APP_VERSION"
INSTANCE_ID: stable-a
APP_VERSION: v1
stable_b:
<<: *backend
environment:
NGINX_ENVSUBST_FILTER: "INSTANCE_ID|APP_VERSION"
INSTANCE_ID: stable-b
APP_VERSION: v1
canary:
<<: *backend
environment:
NGINX_ENVSUBST_FILTER: "INSTANCE_ID|APP_VERSION"
INSTANCE_ID: canary
APP_VERSION: v2
ws_echo:
build: ./websocket
networks: [gateway]
healthcheck:
test:
- CMD
- node
- -e
- "const n=require('node:net').connect(9000,'127.0.0.1',()=>{n.end();process.exit(0)});n.on('error',()=>process.exit(1))"
interval: 3s
timeout: 2s
retries: 10
gateway:
image: nginx:1.30.3-alpine
depends_on:
stable_a:
condition: service_healthy
stable_b:
condition: service_healthy
canary:
condition: service_healthy
ws_echo:
condition: service_healthy
ports:
- "127.0.0.1:8080:80"
- "127.0.0.1:8443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/certs:/etc/nginx/certs:ro
networks: [gateway]
healthcheck:
test: ["CMD-SHELL", "nginx -t >/dev/null 2>&1 && test -s /var/run/nginx.pid"]
interval: 3s
timeout: 2s
retries: 10
networks:
gateway: {}宿主机端口明确绑定 127.0.0.1,避免开发入口无意暴露到局域网。四个后端服务都没有 ports,外部只能经过网关访问。Compose healthcheck 证明进程和监听存在,不证明完整业务链路可用;稍后还会从入口探测 /api/health 和 /ws/echo。
一份配置承载完整请求链
将下面内容保存为 nginx/nginx.conf。它先选 server,再在虚拟主机中选择 location;详细匹配规则可对照 请求处理 和 core module。
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
map $http_authorization $has_authorization {
default 1;
'' 0;
}
map $http_cookie $has_cookie {
default 1;
'' 0;
}
map "$has_authorization:$has_cookie" $skip_cache {
default 1;
"0:0" 0;
}
map $http_x_canary $backend_pool {
default stable_pool;
"1" canary_pool;
}
limit_req_zone $binary_remote_addr zone=api_rate:10m rate=10r/s;
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:10m
max_size=100m
inactive=10m
use_temp_path=off;
log_format gateway escape=json
'{"time":"$time_iso8601",'
'"request_id":"$request_id",'
'"host":"$host",'
'"method":"$request_method",'
'"entry_uri":"$entry_uri",'
'"upstream_uri":"$uri",'
'"status":"$status",'
'"route":"$route_name",'
'"upstream":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"request_time":"$request_time",'
'"upstream_time":"$upstream_response_time",'
'"cache":"$upstream_cache_status",'
'"limit":"$limit_req_status"}';
access_log /var/log/nginx/access.log gateway;
upstream stable_pool {
zone stable_pool 64k;
least_conn;
server stable_a:80 max_fails=1 fail_timeout=10s;
server stable_b:80 max_fails=1 fail_timeout=10s;
keepalive 16;
}
upstream canary_pool {
zone canary_pool 64k;
server canary:80 max_fails=1 fail_timeout=10s;
keepalive 8;
}
upstream websocket_pool {
zone websocket_pool 64k;
server ws_echo:9000;
keepalive 8;
}
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_connect_timeout 2s;
proxy_read_timeout 10s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
server {
listen 80 default_server;
server_name _;
return 404;
}
server {
listen 443 ssl default_server;
ssl_reject_handshake on;
return 404;
}
server {
listen 80;
listen 443 ssl;
server_name app.localhost;
set $entry_uri $uri;
set $route_name local;
ssl_certificate /etc/nginx/certs/app.localhost.pem;
ssl_certificate_key /etc/nginx/certs/app.localhost-key.pem;
location = /gateway-health {
access_log off;
default_type text/plain;
return 200 "gateway-ok\n";
}
location /api/ {
set $route_name $backend_pool;
limit_req zone=api_rate burst=20 nodelay;
limit_req_status 429;
proxy_cache api_cache;
proxy_cache_methods GET HEAD;
proxy_cache_key "$scheme|$request_method|$host|$uri$is_args$args|$backend_pool";
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache $upstream_http_set_cookie;
proxy_cache_valid 200 10s;
add_header X-Request-ID $request_id always;
add_header X-Cache-Status $upstream_cache_status always;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$backend_pool;
}
location /ws/ {
set $route_name websocket_pool;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 60s;
proxy_pass http://websocket_pool;
}
}
}这里没有使用 $proxy_add_x_forwarded_for。当前 Nginx 是最外层可信入口,客户端提交的 X-Forwarded-For 必须被覆盖,应用看到的来源由连接地址 $remote_addr 建立。若 Nginx 前面还有受控负载均衡器,需要根据真实网络配置 set_real_ip_from、real_ip_header 和 real_ip_recursive,而不是信任所有地址:
# 192.0.2.10 是文档示例地址,实施时替换成实际前置代理的精确地址或受控网段。
set_real_ip_from 192.0.2.10/32;
real_ip_header X-Forwarded-For;
real_ip_recursive on;limit_req_zone 随 $binary_remote_addr 计数。启用 real IP 模块后,这个值才代表经过可信代理还原的客户端地址。可信网段配错不仅会让限流失真,还会影响审计、IP 白名单和风控判断。
请求进入虚拟主机时先把 $uri 保存为 $entry_uri,rewrite 后的 $uri 则代表即将发送给上游的路径。日志同时记录二者,但不记录包含查询参数的 $request 或 $request_uri。Authorization、Cookie 和 Set-Cookie 也没有进入日志。业务仍应禁止凭证出现在 URL;故障材料、nginx -T 输出和截图在共享前要检查静态认证头、内部域名与证书路径。
证书先生成,容器才会启动
配置引用了本地证书,启动前先生成 app.localhost 证书。可以按 OpenSSL、mkcert 与 keytool 安装 mkcert,再执行:
mkcert -install
mkcert -cert-file .\nginx\certs\app.localhost.pem `
-key-file .\nginx\certs\app.localhost-key.pem `
app.localhost localhost 127.0.0.1 ::1站点证书可以交给 Nginx 读取,CA 私钥绝不能复制到项目目录。nginx/certs/ 应加入忽略规则,仓库中仅保留生成说明。若网关容器提示 cannot load certificate,先检查文件名、PEM 格式、挂载路径和宿主机读取权限;修改权限时不要把私钥设为所有用户可读。
浏览器到 Nginx 的 TLS 与 Nginx 到上游的连接是两个独立信任边界。上游改为 HTTPS 时,还要配置可信 CA、proxy_ssl_verify on、SNI 和名称校验,不能因为入口有小锁图标就认为链路端到端加密。相关指令可查 HTTP SSL module 与 proxy module。
第一次启动先看配置,再看业务
从 nginx-entry-lab 目录执行:
docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker compose exec gateway nginx -t
docker compose exec gateway nginx -Vdocker compose config 应输出五个服务且没有变量缺失;docker compose ps 最终应显示三个 HTTP 后端、WebSocket 后端和 gateway 均为 healthy。nginx -t 的关键结果是 syntax is ok 与 test is successful。如果 gateway 反复重启,先查看:
docker compose logs gateway --tail 100
docker compose exec stable_a nginx -T常见第一现场包括证书没有生成、宿主机端口已占用、配置挂载成目录、模板变量被错误展开,以及容器网络中不存在上游服务名。nginx -T 会展开完整配置,适合确认 include 和最终值,但输出在分享前必须检查内部地址与认证配置。
记录实际镜像和摘要:
docker compose exec gateway nginx -v
docker image inspect nginx:1.30.3-alpine --format '{{index .RepoDigests 0}}'团队锁定文件应同时保存明确标签与验证过的摘要。摘要变更意味着镜像内容已经变化,即使标签文字相同,也要重新执行后续请求和故障测试。
用同一个健康请求证明 HTTP 与路径改写
Windows PowerShell 使用 curl.exe,避免调用 Invoke-WebRequest 别名;macOS 和 Linux 将命令名换成 curl 即可。
先确认入口进程,再确认代理链:
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/gateway-health
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health第一个请求应返回 200 和 gateway-ok,它只能证明请求抵达 Nginx。第二个请求还应得到:
HTTP/1.1 200 OK
X-Request-ID: <非空值>
X-Cache-Status: MISS
{"instance":"stable-a 或 stable-b","version":"v1","host":"app.localhost","path":"/health","xff":"Docker 网桥来源地址","proto":"http","request_id":"与响应头一致"}输入路径是 /api/health,上游回显路径是 /health,证明 rewrite 已经剥离 /api/。若返回 Nginx 的 404 且日志中没有 upstream,通常是 Host 或 location 未命中;若 upstream status 为 404,说明请求已经到达后端,但上游路径错误。
proxy_pass 是否带 URI、是否使用变量、是否与 rewrite ... break 同时出现,会改变路径传递规则。不要靠肉眼猜测尾斜杠;修改规则后至少验证 /api/health、/api/health/、深层路径、查询参数和重定向。具体语义可查 proxy_pass 指令。
伪造转发头必须失败
客户端主动提交一个伪造来源:
curl.exe -s --resolve app.localhost:8080:127.0.0.1 `
-H "X-Forwarded-For: 203.0.113.9" `
"http://app.localhost:8080/api/health?probe=xff"响应中的 xff 不应是 203.0.113.9,而应是 Nginx 看到的连接来源地址。若伪造值原样进入应用,立刻检查是否误用了 $proxy_add_x_forwarded_for,或是否把过大的网段加入 set_real_ip_from。修复后重复同一命令,直到伪造值不再影响应用事实。
转发头不是普通业务参数。X-Forwarded-Proto 会影响 Secure Cookie 和回调 URL,XFF 会影响审计和授权判断。每增加一层代理,都要画出谁接收连接、谁可信、谁覆盖头、应用信任哪一跳。
HTTPS 成功的标准是不再跳过校验
mkcert 已将本地 CA 加入系统信任后,直接请求 HTTPS:
curl.exe -i --resolve app.localhost:8443:127.0.0.1 `
https://app.localhost:8443/api/health响应应为 200,正文中的 proto 应变为 https。如果当前 curl 没有使用系统证书库,可以显式指定 mkcert 根证书:
$caRoot = mkcert -CAROOT
curl.exe --cacert "$caRoot\rootCA.pem" -i `
--resolve app.localhost:8443:127.0.0.1 `
https://app.localhost:8443/api/health-k 只能用于确认“错误来自证书校验”这一隔离动作,不能作为成功命令。名称不匹配时检查 SAN 与访问域名;未知 CA 时检查客户端信任;链不完整时检查站点证书与中间证书顺序。修复后始终回到不带 -k 的原请求。
两个稳定实例让负载均衡可见
stable_pool 使用 least_conn。它按当前活动连接选择节点,不理解 CPU、队列长度或业务成本。短请求连接数相同时,选择结果会在两个实例间变化;长请求差异较大时,它比简单轮询更容易避开繁忙节点,但仍不能代替容量指标。
为避免缓存掩盖上游选择,给每次请求使用不同查询参数:
1..8 | ForEach-Object {
curl.exe -s --resolve app.localhost:8080:127.0.0.1 `
"http://app.localhost:8080/api/health?lb=$_"
}输出中应同时出现 stable-a 与 stable-b。若始终只有一个实例,先确认两个容器均为 healthy,再查看 access log 的 upstream 字段和最终配置:
docker compose ps
docker compose logs gateway --since 2m
docker compose exec gateway nginx -T节点权重、least_conn、ip_hash、一致性 hash 与 keepalive 会改变流量和状态分布,具体能力可查 upstream module。涉及登录会话时,优先消除本地会话状态;粘性路由容易把容量不均和故障切换问题隐藏到节点宕机时才暴露。
被动失败处理要用停机实验观察
Compose healthcheck 由 Compose 判断容器状态,开源 Nginx upstream 中的 max_fails 与 fail_timeout 则依赖真实请求失败。两者不是同一套健康管理。停止一个稳定实例:
docker compose stop stable_a
1..4 | ForEach-Object {
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
"http://app.localhost:8080/api/health?failover=$_"
}
docker compose logs gateway --since 2m客户端请求应由 stable-b 返回 200。日志可能在同一请求中记录失败的 stable_a 与成功的 stable_b,证明 Nginx 在幂等请求上执行了下一上游尝试。若客户端直接得到 502,检查 proxy_next_upstream、尝试次数、剩余节点和 error log 中的 connect() failed。
恢复节点并等待 fail_timeout 后复验:
docker compose start stable_a
Start-Sleep -Seconds 12
1..6 | ForEach-Object {
curl.exe -s --resolve app.localhost:8080:127.0.0.1 `
"http://app.localhost:8080/api/health?recovery=$_"
}输出应再次出现两个稳定实例。若容器被删除并重建,Docker IP 可能变化,而静态 upstream 名称通常在 Nginx 加载配置时解析;这时先执行 nginx -t 和 reload,再判断节点是否恢复。
默认策略不会在请求已经发送后随意重试非幂等操作。不要为了掩盖 POST 失败而启用 non_idempotent,否则支付、下单和写入可能重复执行。业务幂等、重试预算和失败阶段必须一起设计。
开源 Nginx 的被动失败处理不等于主动探测。共享环境应由编排或监控系统持续请求应用 readiness,并把探测结果、真实流量错误率和节点摘除状态放在同一个视图中。主动健康检查的商业能力与具体发行版差异应从实际构建和 upstream 健康检查说明 确认。
限流要看到拒绝,也要看到恢复
配置按连接来源建立 10r/s 的共享状态区,允许 burst=20,超出后明确返回 429。PowerShell 7 可以并发发送请求:
$codes = 1..40 | ForEach-Object -Parallel {
curl.exe -s -o NUL -w "%{http_code}" `
--resolve app.localhost:8080:127.0.0.1 `
"http://app.localhost:8080/api/health?rate=$($_)"
} -ThrottleLimit 40
$codes | Group-Object | Select-Object Name, Count结果应同时包含 200 与 429。然后检查拒绝证据:
docker compose logs gateway --since 2m | Select-String '"limit":"REJECTED"'
Start-Sleep -Seconds 3
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?rate=recovered等待后请求应恢复为 200。没有出现 429 时,先确认并发是否足够、请求是否来自同一个计数 key、配置是否已 reload;持续 429 时检查速率、burst、真实客户端地址和共享区容量。limit_req 的延迟、拒绝与 dry-run 语义可查 limit_req module。
生产参数不能照抄实验数字。速率应来自上游容量、正常突发、SLO 和降级策略;IP 也未必是公平 key,NAT 后大量用户可能共享地址,登录用户则更适合由可信身份映射限流。限流保护容量,不提供身份认证。
缓存必须证明 MISS、HIT 和绕过
用完全相同的 URL 连发两次:
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?cache=demo
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?cache=demo第一次应看到 X-Cache-Status: MISS,第二次应看到 X-Cache-Status: HIT,且正文中的实例标识保持一致。等待超过示例中的 10 秒后再次请求,应重新出现 MISS。access log 的 cache 字段提供同样证据。
接着用同一个已缓存 URL 携带认证信息,确认入口没有读取共享缓存:
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
-H "Authorization: Bearer lab-only" `
http://app.localhost:8080/api/health?cache=demo
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
-H "Cookie: lab_session=lab-only" `
http://app.localhost:8080/api/health?cache=demo两次响应都应显示 X-Cache-Status: BYPASS,并真正访问上游;它们不能返回前面匿名请求留下的 HIT。最后验证带 Set-Cookie 的响应不会写入共享缓存:
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/set-cookie?cache=session
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/set-cookie?cache=session两次都应到达上游并带 Set-Cookie,X-Cache-Status 不得变成 HIT。若第二次出现 HIT,先检查 proxy_no_cache $upstream_http_set_cookie 是否仍在最终配置中,再清理实验缓存并重复请求。
cache key 包含协议、方法、Host、URI、查询参数和路由池。把 backend_pool 漏掉会让稳定版响应污染候选版验证。Authorization、Cookie 和上游 Set-Cookie 分别经过了真实请求验证;真实业务还要继续区分用户维度、状态码、Vary、容量、失效和主动清理。可以从 proxy_cache 指令 核对指令语义。
缓存写请求、用户资料或强实时数据通常得不偿失。架构师需要先判断内容是否可共享、允许陈旧多久、谁触发失效、缓存不可用时上游是否会雪崩,再决定是否把缓存放在入口层。
灰度必须能稳定识别版本并立即退出
默认请求进入 stable_pool,联调标签 X-Canary: 1 进入 canary_pool:
curl.exe -s --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?release=stable
curl.exe -s --resolve app.localhost:8080:127.0.0.1 `
-H "X-Canary: 1" `
http://app.localhost:8080/api/health?release=canary第一条响应应是 version: v1,第二条应是 version: v2;日志中的 route 分别为 stable_pool 与 canary_pool。cache key 包含路由池,因此两种响应不会共享缓存。
这个头只是本地路由标签,客户端可以伪造,不能承担授权。正式灰度身份应由可信入口根据账号、租户、签名 Cookie 或稳定 hash 建立,并且日志、指标和追踪都能按版本聚合。候选版错误率、延迟或业务指标超过预算时,将 map 中 "1" canary_pool 改为 "1" stable_pool,经过配置检查和 reload 后重复两条请求,二者都应返回 v1。
百分比灰度还要处理用户稳定性、缓存隔离、会话状态、数据库兼容和异步消息。只有入口流量比例而没有数据与协议兼容,不能构成可回滚发布。
WebSocket 需要握手和消息两份证据
Upgrade 与 Connection 是 hop-by-hop 头,代理不会自动转发。配置中的 /ws/ 显式处理了协议升级,细节可查 WebSocket proxying。启动后用 websocat 连接同一入口:
websocat -H='Host: app.localhost' ws://127.0.0.1:8080/ws/echo连接建立后先收到 connected。输入 hello 并回车,应收到同样的 hello,这同时证明 HTTP 101 Switching Protocols 和真实消息往返。保持这个终端不退出,在另一个终端执行:
docker compose exec gateway nginx -t
docker compose exec gateway nginx -s reload
websocat -H='Host: app.localhost' ws://127.0.0.1:8080/ws/echo旧连接应仍能继续回显消息,新连接也应成功建立。若旧连接在 reload 后立即断开,检查 worker 退出日志和容器是否被重建;若连接约 60 秒无消息后断开,这是示例 proxy_read_timeout 的直接结果。真实服务应由应用心跳主动维持连接,并把网关、负载均衡器和客户端的空闲预算按从内到外逐层校准,不能只把超时调大。
一条 JSON 日志把故障定位到具体层
访问日志避免记录查询参数和认证头,却保留定位入口故障所需的字段。正常请求应出现类似信息:
{"request_id":"...","host":"app.localhost","method":"GET","entry_uri":"/api/health","upstream_uri":"/health","status":"200","route":"stable_pool","upstream":"172.x.x.x:80","upstream_status":"200","cache":"MISS","limit":"PASSED"}通过容器日志观察:
docker compose logs gateway --since 5m
docker compose logs stable_a --since 5m
docker compose exec gateway nginx -T诊断时先找第一处异常,而不是从所有配置项同时猜测:
status=404 且 upstream 为空:Host 或 location 没有命中。status=404 且 upstream_status=404:代理已连通,上游路径或应用路由错误。status=502 且 error log 出现 connect() failed:服务名、端口、容器网络或进程状态异常。
status=504 且 upstream time 接近读取超时:应用阻塞、依赖变慢或超时预算过短。limit=REJECTED:入口主动限流,不要按上游故障处理。cache=HIT 且响应版本不对:检查 cache key、灰度池隔离和失效策略。
request ID 必须继续进入应用日志和下游调用;否则入口日志只能证明请求发出,不能证明业务处理到了哪一步。日志平台还要限制访问权限、保存期限和导出范围,避免内部地址与用户行为长期扩散。
故障注入让排障步骤接受反向验证
先停止两个稳定实例:
docker compose stop stable_a stable_b
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?fault=no-upstream
docker compose logs gateway --since 1m预期客户端得到 502,error log 出现连接失败,access log 中 upstream status 记录失败。恢复并复验:
docker compose start stable_a stable_b
Start-Sleep -Seconds 12
docker compose exec gateway nginx -t
docker compose exec gateway nginx -s reload
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?fault=recovered最后请求必须恢复 200,并回显 v1 实例。若容器正常但仍为 502,从 gateway 所在网络确认 DNS 与端口;Nginx 官方镜像未必包含 curl,不要在容器里临时安装工具并把变化当作项目配置,可使用同网络的临时诊断容器或应用自带探针。
再发一个错误 Host:
curl.exe -i http://127.0.0.1:8080/api/health预期返回入口默认 server 的 404,日志不应出现 upstream。加上正确 Host 后恢复 200:
curl.exe -i -H "Host: app.localhost" `
http://127.0.0.1:8080/api/health?fault=host-fixed这两组实验分别证明上游连接失败与路由未命中具有不同证据,团队排障手册应保持这种分型,而不是把所有 4xx/5xx 都归结为“检查 Nginx 配置”。
HTTPS 还要分别验证 SNI 与 HTTP Host。未知 SNI 应在 TLS 握手阶段被默认 443 server 拒绝;正确 SNI 配合错误 Host 则应返回本地 404,两种情况都不能出现 upstream:
curl.exe -i --resolve unknown.localhost:8443:127.0.0.1 `
https://unknown.localhost:8443/api/health
curl.exe -i --resolve app.localhost:8443:127.0.0.1 `
-H "Host: wrong.localhost" `
https://app.localhost:8443/api/health
docker compose logs gateway --since 2m第一条命令预期出现 TLS handshake failure,而不是业务状态码;第二条预期为 404。如果未知名称拿到了业务证书或错误 Host 到达上游,说明 443 default server 或虚拟主机匹配仍有缺口。
reload 的原子性不等于业务自动正确
Nginx master 在收到 reload 后先检查新配置并尝试打开日志和监听 socket。成功时启动新 worker,再让旧 worker 优雅退出;失败时继续使用旧配置。进程机制可查 控制 Nginx 与 命令参数。
这能防止语法错误直接中断服务,却无法发现“语法正确但把 /api 发错上游”的业务错误。实验先从当前正常配置生成一个语法正确、但会把默认请求错误导向候选版的配置:
Copy-Item .\nginx\nginx.conf .\nginx\nginx.conf.last-known-good -Force
$candidate = (Get-Content .\nginx\nginx.conf -Raw).Replace(
"default stable_pool;",
"default canary_pool;"
)
Set-Content .\nginx\nginx.conf.next $candidate -NoNewline
Copy-Item .\nginx\nginx.conf.next .\nginx\nginx.conf -Force
docker compose exec gateway nginx -t
if ($LASTEXITCODE -ne 0) {
Copy-Item .\nginx\nginx.conf.last-known-good .\nginx\nginx.conf -Force
docker compose exec gateway nginx -t
if ($LASTEXITCODE -ne 0) {
throw "恢复文件仍未通过配置检查,禁止 reload"
}
throw "候选配置检查失败,已经恢复文件并阻止 reload"
}
docker compose exec gateway nginx -s reload
curl.exe -s --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?candidate=wrong-route候选配置可以通过 nginx -t,但最后一条请求会返回 v2,证明语法检查不能代替业务检查。发现默认流量版本错误后,把完整配置文件作为一个单元恢复;恢复配置若未通过检查,脚本必须在 reload 前停止:
Copy-Item .\nginx\nginx.conf.last-known-good .\nginx\nginx.conf -Force
docker compose exec gateway nginx -t
if ($LASTEXITCODE -ne 0) {
throw "已知正常配置未通过检查,禁止 reload"
}
docker compose exec gateway nginx -s reload
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
http://app.localhost:8080/api/health?rollback=stable
curl.exe -i --resolve app.localhost:8080:127.0.0.1 `
-H "X-Canary: 1" `
http://app.localhost:8080/api/health?rollback=canary
curl.exe -i --resolve app.localhost:8443:127.0.0.1 `
https://app.localhost:8443/api/health?rollback=https
curl.exe -i http://127.0.0.1:8080/api/health
websocat -H='Host: app.localhost' ws://127.0.0.1:8080/ws/echo稳定请求恢复 v1、显式灰度仍返回 v2、HTTPS 返回 200、错误 Host 返回 404、WebSocket 能完成消息往返,才算配置代际已经恢复。单文件切换配合 reload,使 Nginx 看到的是一整代配置;大型项目若拆成多个 include,应由版本化目录、不可变制品或原子符号链接切换整套配置,避免新旧文件混用。
WebSocket、SSE 和大上传会让旧 worker 继续存活。回滚观察不仅看新请求,还要看 worker 代际、活动连接、最长会话、重连峰值和资源占用。多个 Nginx 实例前还有外层负载均衡时,配置代际、连接排空和回滚动作必须跨实例协调。
团队把入口当作受控工程资产
项目仓库中保留 Compose、Nginx 配置、证书生成说明、smoke test 和回滚脚本。私钥、内部 CA 私钥、真实 Cookie、Authorization、ACME 状态与生产域名不能进入仓库、镜像层、构建日志或截图。运行身份只获得读取站点私钥、写入必要缓存和日志目录的权限;宿主机通过端口映射提供 80/443 时,不必让 worker 长期拥有额外系统权限。
每条路由应记录域名、输入 path、上游实际 path、上游 owner、TLS owner、是否缓存、限流 key、灰度身份、超时预算和回滚版本。配置发布至少自动执行镜像内 nginx -t、HTTP/TLS smoke test、错误 Host、错误上游、WebSocket 与灰度检查,并保存 request ID、状态码和日志片段。
升级前先比较实际 nginx -V、变更记录、编译模块、基础镜像和默认行为。团队不应把个人电脑上的 nginx.conf 直接复制到共享环境,也不应依靠手工修改容器内部文件;容器重建后这些变化会消失,且无法审计。
从本地入口走向生产拓扑时重新做取舍
开发机单实例的目标是稳定复现和快速排障,不提供高可用。共享环境可以运行多个 Nginx 实例,但还需要外层负载均衡、配置一致性、证书轮换、日志汇聚、连接排空和故障演练。把实例数量从一改成二,并不会自动得到完整的生产入口平台。
Nginx 适合路由相对稳定、配置显式、团队熟悉 reload 与日志证据的 HTTP 入口。服务频繁增删且依赖自动发现时,Traefik 通常减少手工维护;希望工具直接管理 HTTPS 时,Caddy 更简洁;需要复杂过滤链、动态控制面和细粒度遥测时,Envoy 更合适。选型依据是配置变化频率、失败传播、证书责任、观测能力、团队维护成本和回滚速度,不是功能列表长度。
入口重试、缓存、限流和灰度都会改变业务语义。非幂等写入可能被重复执行,错误 cache key 可能跨用户泄露数据,错误客户端 key 可能误伤整片 NAT 用户,灰度版本可能读取不兼容数据。架构师应把这些能力与应用幂等、数据兼容、容量预算和发布策略一起评审。
离开开发机前再走一遍证据链
docker compose ps 显示网关、三个 HTTP 后端与 WebSocket 后端健康,实际镜像标签和摘要已有记录。HTTP 请求证明 Host、/api 路径剥离、request ID 和转发协议正确。伪造 XFF 不会改变应用看到的客户端来源,可信代理地址采用最小集合。
HTTPS 请求不依赖 -k,证书 SAN、链和客户端信任均正确,私钥没有入库。两个稳定实例都能接收请求,停止与恢复节点时有 upstream 和 error log 证据。限流出现预期 429 并能恢复,缓存完成 MISS、HIT、过期和敏感请求绕过判断。
灰度响应、日志、指标和 cache key 能区分版本,关闭灰度后全部请求返回稳定版。WebSocket 完成 101、真实消息往返、空闲超时和 reload 期间连接观察。错误 Host、错误路径、上游停机和证书错误都能定位到请求链中的具体层。
候选配置先通过镜像内检查,业务错误可以恢复完整配置代际并用原请求复验。日志不记录查询参数、认证头或 Cookie,故障材料共享前完成敏感信息检查。路由、证书、上游、限流、缓存、灰度和回滚都有明确 owner。
实验结束后停止并清理容器、网络与匿名缓存数据:
docker compose down --remove-orphans证书文件是否删除取决于项目是否继续使用;删除前确认没有其他本地服务引用。CA 私钥由 mkcert 自己管理,不能随项目目录一起分发。
