返回博客

「读取失败:读取失败」——真实错误栈藏在容器 stderr 里

某天,某个后台管理页面突然渲染出一行字:「读取失败:读取失败」。前缀和消息完全一样,前端开发第一反应是"是不是前端写错了",反复查模板逻辑,毫无头绪。打开 Network,curl 一下那条 API:

$ curl https://your-service/api/config/enums?table=award_grades
{"error":"server_error","message":"读取失败"}

字面量干净得无可挑剔——完全没有 stack,没有行号,没有任何可以追查的信息。

现象

  • API 返回体只有两个字段:errormessage,值均为人类可读的泛化字符串。
  • 前端用 前缀 + ": " + message 拼接展示,于是变成了「读取失败:读取失败」这种重复字面量——让人误以为是前端 bug,实际上是 service 在 catch 块里把同一个 generic 字面量填了两遍。
  • 直接查 service 源码,找到 catch 块,确认 pattern:
try {
  // 读数据库、调外部接口……
} catch (error) {
  console.error('读取数据库枚举失败:', error)  // ← 真实 stack 在这
  return errorResponse('读取失败', 500)        // ← client 只能看到这
}

真因在 console.error 那一行,永远不会到达 client。

根因

Service 端的防御性 catch 是标准写法:把真实 error 打到 stderr,返回给 client 一个不泄露内部细节的 generic message。问题在于,当你不是 service owner、不能 docker exec 进容器的时候,stderr 对你完全不可见。

容器化部署环境里,stderr 通常由 log shipper(Fluentd / Loki / 自研 sink 等)流到集中日志服务。真实的错误栈并没有消失,只是流到了你没想到去看的地方。

坑:把 generic message 误认为「没有日志」

看到 {"message":"读取失败"} 就以为服务端"吞掉"了错误、毫无记录——其实 service 已经 console.error 了完整 stack,只是你看不到。排查止步于此,等于自己封住了线索。

排查步骤

第一步:确认 service 有打 stderr。

grep -rnE "console\.error|logger\.error|log\.Error" service/src | head -20

看到 console.error('xxx 失败:', error) 这样的模式,说明真 stack 确实流进了 stderr。如果没有任何 stderr 输出,才需要回头要求 service 加日志。

第二步:从 log sink 反查。

找到 log shipper 的查询 endpoint,用 service 端 console.error 的真实前缀做关键词搜索(中文 URL encode 一下):

curl -sH "X-Sink-Key: $SINK_KEY" \
  "http://<log-sink-host>/logs?module=docker.<service-name>&level=error&since=2d&q=%E8%AF%BB%E5%8F%96%E6%95%B0%E6%8D%AE%E5%BA%93%E6%9E%9A%E4%B8%BE&limit=20"

立刻看到完整 stack:

{
  "ts": 1748500000000,
  "module": "docker.your-service",
  "level": "error",
  "msg": "读取数据库枚举失败: Error: Unknown column 'parent_id IS NULL' in 'field list'"
}

根因一目了然:某条元数据配置里 columns_config 被填入了 SQL 表达式(parent_id IS NULL)当成字段名,被反引号包裹后 MySQL 报 unknown column。

正解

正解:去 log sink 按 service 端日志前缀捞 stderr

不要在 client 侧死磕 response body。service 端已经 console.error 了真实 stack,去 log sink 按 service 端日志前缀关键词搜索,通常 1 条就能拿到完整错误信息。

如果你 service owner,可以考虑在 dev / 内网场景下把真实 message 回传给 client:

// before
catch (error) {
  console.error('读取失败:', error)
  return errorResponse('读取失败', 500)
}
 
// after(仅 dev / 内网 admin 工具)
catch (error) {
  console.error('读取失败:', error)
  return errorResponse(
    error instanceof Error ? error.message : '读取失败',
    500,
  )
}

注意:public API 必须保持 generic message,真 stack 只流 sink + 给 client 一个 trace_id,防止泄露 SQL 列名、路径、内部 key 等敏感信息。

多层 service 链的情形

service A 调 B 调 C,每层都 catch + generic——真因 stack 在最下游 C 的 stderr。要按「上游 → 下游」反向走,逐个 sink 模块查,不要只查第一层。

一句话外卖

Generic error response 不是「没有日志」,是「日志在 stderr,stderr 在 sink」——排查从 log sink 而不是 response body 出发。