「读取失败:读取失败」——真实错误栈藏在容器 stderr 里
某天,某个后台管理页面突然渲染出一行字:「读取失败:读取失败」。前缀和消息完全一样,前端开发第一反应是"是不是前端写错了",反复查模板逻辑,毫无头绪。打开 Network,curl 一下那条 API:
$ curl https://your-service/api/config/enums?table=award_grades
{"error":"server_error","message":"读取失败"}字面量干净得无可挑剔——完全没有 stack,没有行号,没有任何可以追查的信息。
现象
- API 返回体只有两个字段:
error和message,值均为人类可读的泛化字符串。 - 前端用
前缀 + ": " + 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 等)流到集中日志服务。真实的错误栈并没有消失,只是流到了你没想到去看的地方。
看到 {"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。
正解
不要在 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 A 调 B 调 C,每层都 catch + generic——真因 stack 在最下游 C 的 stderr。要按「上游 → 下游」反向走,逐个 sink 模块查,不要只查第一层。
一句话外卖
Generic error response 不是「没有日志」,是「日志在 stderr,stderr 在 sink」——排查从 log sink 而不是 response body 出发。