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 没能从上游拿到有效响应」。按发生频率排:

  1. app 没在监听。容器崩溃、启动失败、健康检查未通过,Nginx 连过去就是 connection refused。
  2. app 换了 IP,Nginx 还在用旧地址。容器重建后 IP 变了,启动时解析的地址已经失效。
  3. 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. 健康检查与回滚决策

健康检查只能回答「进程还能不能响应」,不能回答「功能是否正常」。所以回滚决策要看三类信号:

  1. 错误率与延迟:5xx 持续升高,或者 P99 明显劣化;
  2. 健康状态:unhealthy 且 RestartCount 增长;
  3. 发布关联:故障时间点与最近一次镜像更新、配置变更、数据库迁移重合。

确认要回滚后,按「先应用、后入口」的顺序:

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。

排障的顺序比工具重要:先定位责任方,再缩小到网络、超时、权限三个抽屉,最后才是改配置和重启。这套顺序能保证每一次重启都是因为知道了原因,而不是因为不知道原因。