502 和 504 看起来都是「网关出错」,实际责任方完全不同:前者通常是连不上后端,后者是后端没在约定时间内答完。这篇把 Limit Blog 线上遇到过的故障按「先定位责任方,再查网络、超时、权限」的顺序整理成一份可执行手册,命令都可以直接复制。
1. 先判断是谁返回的错误
拿到一个 5xx,第一步不是重启,而是确认这句话是谁说的。三个信号足够区分:
- 响应头。Nginx 会带
Server: nginx/1.27.x,Go 服务默认不带这个头。 - 响应体。Nginx 的 502/504 是 HTML 错误页;应用层的错误是统一的
{"code":...,"message":...}信封,而 chi 超时产生的 504 是空 body。 X-Request-Id。应用侧的RequestID中间件会把它写进响应头;Nginx 自己生成的 502 没有这个头。
curl -skD- https://example.com/api/health
如果响应头里有 X-Request-Id,说明请求已经进了 Go 进程,问题在应用或它的下游;如果没有,请求死在 Nginx 与 app 之间,先查容器和网络。这个判断能省掉一半的无效排查,因为两边的日志体系完全不同。
2. 502 的三种来源
502 的定义是「作为网关的 Nginx 没能从上游拿到有效响应」。按发生频率排:
- app 没在监听。容器崩溃、启动失败、健康检查未通过,Nginx 连过去就是 connection refused。
- app 换了 IP,Nginx 还在用旧地址。容器重建后 IP 变了,启动时解析的地址已经失效。
- Nginx 配置本身有问题。比如 upstream 端口写错、
proxy_pass指向了不存在的服务名,重载失败后仍在用旧配置。
app 的健康检查写在 Compose 里,先看它是怎么定义的:
app:
image: ${APP_IMAGE:-limit-blog-app:latest}
container_name: limit-blog-app
environment:
APP_CONF: ${APP_CONF:-/app/config/production.mysql.yml}
expose: ["8000"]
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8000/api/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
mysql: { condition: service_healthy }
start_period 内失败不计入 retries,这是给进程启动留的宽限。如果 app 启动要 40 秒,而 start_period 只有 15 秒,健康检查可能在初始化完成前就把容器标成 unhealthy,进而让 Nginx 一直等不到启动条件。
数据库的健康检查同样是启动链的一环:
mysql:
image: mysql:8.0.42
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 3s
retries: 20
MySQL 没 healthy,app 就不会启动,Nginx 又在等 app。如果 Nginx 已经在运行,表现就是整站 502;首次启动则卡在等待链上。所以 502 不一定是 app 的问题,也可能是启动链最上游的数据库卡住了。
再看实际状态:
docker compose -f deploy/docker-compose.yml ps
docker inspect limit-blog-app --format '{{.State.Health.Status}} {{.RestartCount}}'
Restarting 或 RestartCount 持续增长,说明 app 在崩溃循环,直接看日志;running 但 health=unhealthy,看健康检查本身是否被代理或鉴权挡住。
3. 容器网络与 DNS
Compose 会创建一个默认网络,服务名就是 DNS 名。app、mysql、site-builder 之间互相访问都用服务名,不要写容器 IP,也不要写 localhost——后者在容器里永远指自己。
resolver 127.0.0.11 valid=10s ipv6=off;
location /api/ {
set $api_upstream app:8000;
proxy_pass http://$api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/ {
set $admin_upstream app:8000;
proxy_pass http://$admin_upstream;
proxy_set_header Host $host;
proxy_read_timeout 60s;
}
127.0.0.11 是 Docker 内置 DNS,只在自定义网络里存在。用变量写 proxy_pass 会强制 Nginx 在请求时解析,valid=10s 决定它多久重新确认一次地址。如果写成 proxy_pass http://app:8000,解析只发生在启动时,之后不会再变。
/admin/ 单独写一段不是冗余:管理端也反代到 app,但它有自己的 CSP 和超时。很多「公开站正常、后台 502」的工单,最后发现是这段被漏掉或指向了旧服务。
查 DNS 是否指向当前容器:
docker compose -f deploy/docker-compose.yml exec nginx nslookup app
解析对但连不通,查容器是否在监听 8000、是否同一个网络;解析就不对,查 resolver 和 valid,静态写法需要 nginx -s reload。
还有一种「网络看起来正常但连不上」:容器不在同一个网络里。手工 docker run 起的调试容器默认进 bridge,自然解析不到 app。用 docker compose exec 而不是 docker run 能避免这类误判。
4. 504 的两层超时
504 的定义是「上游在规定时间内没有响应」。这套部署里有两层独立超时,返回的 504 长得不一样:
- 应用层 504:chi 的
Timeout中间件在server.timeout.request(30 秒)到期后写504,body 为空。 - Nginx 层 504:
proxy_read_timeout(/api/是 60 秒)到期仍没收到响应,Nginx 返回自己的 HTML 错误页。
正常情况下应用层先触发,因为 30 秒小于 60 秒。但 chi 的超时不是抢占式的:它只在 handler 返回后检查 context,如果 handler 卡在数据库连接或死锁里一直不返回,应用层的 504 根本写不出来,最后只能由 Nginx 在 60 秒时兜底。这也解释了为什么「应用日志里什么都没有,但用户看到 504」——请求还在 goroutine 里挂着,访问日志要等 handler 返回才会写。
所以看到 504,先看时间:30 秒左右大概率是应用层超时,60 秒左右才更像 Nginx 兜底。两者相差一倍,用 curl -w 或浏览器 Network 面板的时间就能区分。
server:
timeout: # 单位:秒
request: 30 # chi Timeout,必须小于 write
readHeader: 10
read: 60 # 需容纳上传
write: 120
idle: 120
shutdown: 15
这些值的约束关系比数值本身重要:
request必须小于write,否则超时响应还没写完,连接先被写超时切断;read要容纳上传,32MB 的媒体文件在慢网络上很容易超过 60 秒;shutdown是优雅停机的总预算,滚动重启时决定旧进程还能处理多久存量请求。
哪些接口容易踩 504:备份导出、全文检索重建(/api/admin/search/reindex)、正文格式迁移(/api/admin/maintenance/migrate-content)、WXR 导入。它们的共同点是「一次请求做完整批处理」。正确的方向是改成异步任务加进度查询,而不是把 30 秒调到 300 秒——后者只是把问题推迟,还会拖垮并发。
5. SSE:长连接不能套普通超时
站点构建状态走 SSE,是 504 和「连接反复断开」的高发区。三层都要放行:
location = /api/admin/site/build/events {
set $sse_upstream app:8000;
proxy_pass http://$sse_upstream;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 1h;
}
- Nginx 关闭
proxy_buffering,否则事件会攒批发送,界面看起来卡住; proxy_read_timeout放大到 1 小时,普通 API 的 60 秒会周期性掐断长连接;- Go 侧用
TimeoutExcept跳过 chi 的 30 秒超时,并SetWriteDeadline(time.Time{})清掉 120 秒写超时。
少任何一层,表现都是「页面打开几分钟后状态不再更新」,因为前端 EventSource 会自动重连,看起来又能用了,所以很容易被忽略。如果浏览器报 ERR_HTTP2_PROTOCOL_ERROR,先确认上面这段 location 是精确匹配且排在 /api/ 之前——Nginx 的前缀匹配取最长,精确匹配优先级更高。
6. 卷权限与只读文件系统
权限问题的典型日志是 permission denied、read-only file system、cannot create directory。先确认挂载关系:
services:
app:
volumes:
- ../config:/app/config:ro # 只读配置
- ../data:/app/data # 读写产物与上传
nginx:
volumes:
- ../data:/srv/limit-blog/data:ro
mysql:
volumes:
- ../data/mysql:/var/lib/mysql
然后看容器里实际拿到的挂载:
docker inspect limit-blog-app --format '{{json .Mounts}}'
再在容器里做一次写探针:
docker compose -f deploy/docker-compose.yml exec app \
sh -c 'touch /app/data/.write-probe && rm /app/data/.write-probe'
报 Read-only file system 说明挂载是 :ro,或者容器用了只读根文件系统;报 Permission denied 则是 UID 不匹配。
这套部署里 app 和 site-builder 容器默认以 root 运行,所以 data/ 下的文件属主也是 root,宿主机上清理要 sudo。真正容易出问题的是 MySQL:镜像以 mysql 用户跑 mysqld,如果 data/mysql 被 root 或其他镜像初始化过,就会启动失败。
docker compose -f deploy/docker-compose.yml exec mysql id mysql
确认 UID 后用同一个 UID 修正属主,不要直接 chmod 777:
DC="docker compose -f deploy/docker-compose.yml"
U=$($DC exec -T mysql id -u mysql)
G=$($DC exec -T mysql id -g mysql)
sudo chown -R "$U:$G" data/mysql
还有两个容易误判的点:
- 配置目录是
:ro挂载,容器内不能写,但宿主机改文件后重启容器依然生效。只读挡的是容器进程,不是部署操作。 - 日志目录
/app/data/logs由 app 运行时创建。把data/整体挂成只读,app 会在启动阶段失败,而不是等到写第一条日志。
7. 日志定位与 requestId 串联
一次请求至少产生两条日志:访问日志和业务错误日志,它们靠 requestId 关联。访问日志由 Logger 中间件写,字段包括 requestId、method、path、from、status、size、time;业务错误由 MapError 写 request failed,带同一个 requestId 和错误详情。
先从响应头拿到 ID:
curl -skD- https://example.com/api/health | grep -i x-request-id
再按 ID 把一次请求的所有日志捞出来:
docker compose -f deploy/docker-compose.yml logs --since=30m app | grep '<request-id>'
日志输出位置由配置决定:
log:
level: info
path: /app/data/logs/server.log
mode: console # file / console / both
encoding: console # json / console
maxBackups: 30
maxAge: 7
maxSize: 1024
生产配置用的是 console,所以日志进 Docker 的 stdout,用 docker compose logs 看;切成 both 或 file 后才会落到 /app/data/logs/server.log,那时要进容器或从宿主机 data/logs 读。排障时先确认 mode,否则会在错误的地方翻半天。
Nginx 侧的错误在它自己的日志里,关键字是 connect() failed、upstream timed out、no live upstreams。upstream timed out 对应 504,connect() failed 对应 502。把两边的日志按时间对齐,基本能还原一次故障的完整时序。
8. 健康检查与回滚决策
健康检查只能回答「进程还能不能响应」,不能回答「功能是否正常」。所以回滚决策要看三类信号:
- 错误率与延迟:5xx 持续升高,或者 P99 明显劣化;
- 健康状态:
unhealthy且RestartCount增长; - 发布关联:故障时间点与最近一次镜像更新、配置变更、数据库迁移重合。
确认要回滚后,按「先应用、后入口」的顺序:
APP_IMAGE='limit-blog-app:<previous-tag>' \
docker compose -f deploy/docker-compose.yml up -d --force-recreate app
docker compose -f deploy/docker-compose.yml exec nginx nginx -s reload
--force-recreate 不能省:只 restart 不会重新读取环境变量和镜像 tag。入口层如果用的是静态上游解析,app 重建后必须 reload Nginx;用变量写法可以等 valid 到期,但 reload 更快。
回滚的目标不是「让旧代码跑起来」,而是「让整条链路回到已知状态」。所以镜像、配置、入口三者要一起确认,不能只换其中一个就宣布恢复。
数据库是回滚里唯一不能靠镜像解决的部分。迁移是向前执行的,旧镜像配新表结构通常还能跑,但反过来(新代码写了旧镜像不认识的数据)会出问题。所以回滚前先确认这次发布有没有改表结构;有的话,应用回滚和数据库恢复要一起评估。
还有一点常被忽略:回滚后要重新验证入口。镜像回滚只保证进程是旧版本,Nginx 可能还缓存着旧上游地址,静态站的 current 也可能指向新产物。应用、入口、静态产物三者版本不一致时,故障往往比回滚前更难查。
9. 一份可执行的排查清单
按顺序执行,基本能覆盖大部分线上故障。第一行只是把重复的 Compose 前缀收成一个变量,后面每条都能直接跑:
DC="docker compose -f deploy/docker-compose.yml"
# 1. 入口、容器、健康
curl -skD- https://example.com/api/health
$DC ps
docker inspect limit-blog-app --format '{{.State.Health.Status}} {{.RestartCount}}'
# 2. 连通性与配置
$DC exec nginx wget -qO- http://app:8000/api/health
$DC exec nginx nslookup app
$DC exec nginx nginx -t
# 3. 日志与 requestId
$DC logs --since=30m --tail=200 app nginx
$DC logs --since=30m app | grep '<request-id>'
# 4. 写权限
$DC exec app sh -c 'touch /app/data/.write-probe && rm /app/data/.write-probe'
清单之外,有两个习惯值得保留:每次变更只动一个变量,出问题才能归因;排障过程中把执行过的命令和结果记下来,事后能补成 runbook,下次少走弯路。清单本身也应该随故障更新,每遇到一个没覆盖的场景就补一条,它才不会被时间淘汰。
10. 小结
502 查「连不上」:容器是否活着、DNS 是否指向新 IP、Nginx 是否还在用旧配置。504 查「没答完」:是应用层 30 秒超时,还是 Nginx 60 秒兜底,还是 SSE 这类长连接没被排除。权限问题先看挂载的 RW 和容器内 UID,再谈 chmod。
排障的顺序比工具重要:先定位责任方,再缩小到网络、超时、权限三个抽屉,最后才是改配置和重启。这套顺序能保证每一次重启都是因为知道了原因,而不是因为不知道原因。



评论
加载中…