
一个 Linux 上的 .NET 容器偶发重启,管理端没有异常,预设的 dump 目录也是空的。唯一的线索就是容器内产生了 core.1 的文件,如果能方便拿到这个 core.1 文件,分析出问题会很容易。
但客户的环境是内网环境,文件也没有办法外发。
最后通过一个离线分析镜像,从 Linux core 的原始栈内存中统计出 582 个重复返回地址,再将地址还原成具体方法,找到了问题。
本文大概涉及下面一些内容:
- 怎样区分 OOM、应用崩溃和外部重启。
- 怎样在联网机器上构建可带入内网的分析镜像。
- 怎样从事故镜像中提取精确的 Runtime、App 和 Rootfs。
- 怎样在隔离容器中完成 GDB、dotnet-dump 和 SOS 分析。
- 当
clrstack已经损坏时,怎样扫描原始栈、统计重复地址、解析ip2md和 MethodDef token。
1、环境
本文的命令和脚本针对以下环境验证:
- Linux x 86_64/amd 64;
- Docker;
- glibc 发行版,不包含 Alpine/musl;
- .NET Core 2.1;
- Linux ELF core dump;
- 分析机可以运行 Docker,但可以完全断网。
2、先需要确认是哪一种“重启”
“容器重启了”只是现象。同一个容器可能因为应用崩溃、OOM、人工 docker restart、定时任务、主机重启或健康检查而重启。
2.1 、查看当前状态
| |
注意:docker inspect 的 .State 只代表当前或最近一次状态,容器再次启动后,旧的退出现场可能被覆盖。
2.2、用 Docker events 还原当时发生了什么
| |
常见信号和退出码:
| 现象 | 常见含义 | 是否一定代表该原因 |
|---|---|---|
ExitCode=137 | 128 + 9,进程最终收到 SIGKILL | 不一定是 OOM,docker restart 超时后也会发 SIGKILL |
ExitCode=139 | 128 + 11,SIGSEGV | 需要结合应用日志和 core |
OOMKilled=true | Docker/cgroup 记录了 OOM kill | 强证据,但还应查主机内核日志 |
先 kill signal=15 再 kill signal=9 | 先 SIGTERM,宽限期过后 SIGKILL | 通常是外部 stop/restart,不是应用自崩溃 |
SIGKILL 无法被进程捕获,所以这类事件通常不会留下 managed dump。
故障发生时,服务器的内存占用不高也不能单独排除 OOM。要同时看容器 cgroup 限额、容器峰值和内核记录,MemoryLimit=0 表示 Docker 没设容器上限
最后通过日志查到有 StackOverflow,所以基本可以排除是内存爆了。
| |
2.3、进一步确认 StackOverflow
| |
如果同一时间点同时出现:
| |
2.4、为什么配置的 dump 目录是空的,其他目录却有 core.1?
这往往是两套机制:
COMPlus_DbgEnableMiniDump等变量依赖 .NET Runtime 在致命错误路径中调用 createdump。core.1可能是 Linux 内核根据kernel.core_pattern、ulimit -c和进程工作目录生成的 ELF core。
StackOverflow 发生时线程栈已接近耗尽,老版 Runtime 不一定能完整走完 managed dump 路径。因此,预设 dump 目录为空,不能推导出“dump 配置没有生效”。
3、为什么内网分析需要一个专用镜像
只有 core 通常不够。对 Linux .NET Core dump 做可靠分析,至少需要:
- GDB、
file、readelf、eu-readelf等原生工具; - 能运行的
dotnet-dump和 SOS; - 事故时的精确
dotnet、libcoreclr.so、libmscordaccore.so; - 事故镜像中的应用 DLL/PDB;
- 尽可能精确的 glibc、libpthread、libstdc++ 等 Rootfs 原生库。
在生产容器里 yum install gdb 或安装 SDK 会改变现场,没有外网也无法直接直接安装,离线装也非常的麻烦,而且也不符合客户的要求。
更好的做法是:
| |
4、在联网机器上构建离线分析镜像
4.1、准备目录
| |
最终目录结构如下:
| |
4.2、 prepare-analyzer.sh:下载和解包离线工具
保存下面的完整脚本为 prepare-analyzer.sh:
| |
执行:
| |
这一步必须在联网机器上运行。
4.3、 完整 Dockerfile
将下面的内容保存为 Dockerfile:
| |
这个镜像中的 .NET Core 2.1.30 是“工具运行时”,它用来启动 dotnet-dump 3.0.47001。它不代替事故现场的 Runtime。分析 core 时,仍要额外挂载事故镜像里的精确 Runtime。
4.4、 通用分析脚本 analyze-core-offline.sh
它的职责是做第一轮“广谱检查”:确认 core 身份,采集原生线程栈,再尝试提取托管线程、异常和 GC 堆摘要。它还通过 timeout 和 ulimit 限制执行时间与报告大小,避免损坏的 core 把日志写到十几 GB。这是在反反复复尝试很多次后得出的经验。
把下面内容保存为 analyze-core-offline.sh:
| |
这里使用的是容器内统一路径。后面运行脚本会把宿主机上的 core、Runtime、App、Rootfs 分别只读挂载到这些位置。
4.5、 构建、验证和导出
此时目录中的四个文件已经齐全,可以执行:
| |
Dockerfile 的 yum 阶段需要联网,内网机器只加载已经构建好的 tar 包,不在内网重新 build。
5、 内网排查
上面所做的事情都是在我本机电脑上做的准备,把相关的文件拷贝到内网,就可以开始排查了。
5.1、 导入分析镜像
把 core-analyzer.tar.gz 和 SHA256SUMS 拷贝到内网机器:
| |
5.2、为什么必须是“精确”镜像
分析 .NET core 需要四个内容:
- core 文件;
- 生成 core 的那份 Runtime,特别是
libcoreclr.so和libmscordaccore.so,这个可以从出问题; - 当时的 App DLL/PDB;
- 当时镜像的 glibc、libpthread、libstdc++ 等原生库。
只是 tag 同名并不够,tag 可以被重新 push。应优先记录 Image ID 或 digest,并对关键文件做 SHA-256。
5.3、完整提取脚本 extract-exact-image.sh
这个脚本只创建一个停止状态的容器,不会启动业务服务。
| |
使用:
| |
如果镜像声明了 VOLUME /app,docker export 不会包含卷中的内容,所以脚本额外用 docker cp 提取 App。如果业务启动后还会对卷中 DLL 做替换,就要在授权前提下从事故容器复制该目录,并单独保存哈希。
6、第一轮通用 core 分析
我是在生产环境的服务器上拿到所有需要的内容后,拷贝到单独的一台测试服务器进行分析,避免对生产系统造成影响。
6.1、宿主机启动脚本 run-core-analysis.sh
| |
执行:
| |
建议按以下顺序阅读:
| |
6.2、常见错误怎么读
| 输出 | 通常意味着什么 | 处理 |
|---|---|---|
wrong library or version mismatch | Rootfs 或共享库不是事故镜像的那份 | 重新提取精确 Rootfs,不要使用分析机自带的 libc |
Can not load or initialize libmscordaccore.so | DAC 与 core 中 Runtime 不匹配,或路径没指对 | 核对 libcoreclr.so 和 libmscordaccore.so 是否来自同一 Runtime 目录 |
Failed to request Module data from assembly | App DLL 缺失、版本不符,或托管栈已损坏 | 先比对 DLL 哈希;若精确一致,转原始栈扫描 |
方法后显示 Unknown | 元数据/PDB 不足,或 SOS 只恢复了模块 | 可用 ip2md 取 MethodDef token,再离线解析 DLL |
Unrecognized command 'setclrpath' | 所用的老版 dotnet-dump 不支持该交互命令 | 不要继续重试命令;把精确 Runtime 挂载到 core 记录的原路径 |
| SOS 反复找不到目标 Runtime | Runtime 没挂载到 core 记录的原路径 | 本文示例将精确 Runtime 挂载到 /usr/share/dotnet;如事故镜像路径不同,同步修改挂载目标 |
| 托管栈只剩一两帧 | StackOverflow 可能已覆盖常规展开所需的栈链 | 不是 core 无用,转入下一节扫描信号线程原始栈 |
到这里,如果 clrstack -all 已经给出了清晰的重复调用序列,就可以直接转向代码。如果只看到 abort / SIGSEGV、ArrayCopy 和大量 Unknown,则需要 StackOverflow 专项方法。
7、StackOverflow 专项分析:托管栈损坏时扫描原始栈
7.1 先确认信号线程和扫描起点
StackOverflow 终止过程在 Linux/.NET Core 2.1 上可能最终表现为 abort 或 SIGSEGV。先在第一轮 GDB 报告中找到信号线程的 LWP 和原生栈:
| |
典型原生栈可能是:
| |
这里的 frame 4 只是一个真实示例,不是通用常量。不同 Runtime 补丁、不同崩溃阶段都可以让有效业务栈帧出现在 frame 3、5 或其他位置。应从 abort 向下找第一个能正常读取 $rsp 的崩溃前栈帧,并把帧号作为脚本参数。
7.2、完整脚本 analyze-stackoverflow-raw-stack.sh
这个脚本做七件事:
- 用 GDB 定位信号线程;
- 从指定栈帧的
$rsp开始只扫描 256 KiB; - 统计重复的 8 字节值;
- 最多保留 512 个候选地址;
- 通过
clrthreads把 LWP/OSID 映射到 SOS 线程; - 对候选地址执行
ip2md,取出 MethodDef token; - 在分析镜像内用 Python 标准库解析精确 DLL,得到完整方法名。
保存为 analyze-stackoverflow-raw-stack.sh:
| |
脚本没有把发现的每个数字都视为方法地址;它先做频次排序,然后交给 ip2md 验证。这一步很重要,因为栈上同样会反复出现对象地址、数组地址、长度和标志位,“频次高”不等于“一定是代码”。
7.3、无第三方依赖的 resolve-dotnet-method-token.py
ip2md 在老 Runtime 和无 PDB 环境中,可能只返回这样的结果:
| |
06001234 是 MethodDef token。下面的解析器只使用 Python 2.7/3 标准库,读取 PE/CLI 元数据中的 TypeDef 和 MethodDef 表,无需 SDK、Mono 或 NuGet 包。保存为 resolve-dotnet-method-token.py,与上一个 shell 脚本放在同一目录。
| |
它必须解析生成 core 时的那份 DLL。即使方法名没变,重新编译也可能让 MethodDef 行号变化,因此不能拿本地另一个版本的 DLL 替代。
7.4、执行与读取结果
| |
如果不传 OSID,脚本会尝试从 GDB 的当前线程自动取 LWP;但扫描帧号仍应人工检查。执行后先看:
| |
七个主要报告的关系如下:
| 文件 | 用途 |
|---|---|
00-summary.txt | 记录 core/DLL 哈希、Runtime、OSID、帧号和各阶段退出码 |
01-native-stack-scan.txt | GDB 信号线程、寄存器和 256 KiB 原始栈 |
02-repeated-pointers.txt | 候选 8 字节值按重复次数排名 |
03-clrthreads.txt | SOS 线程与 OSID 映射 |
04-ip 2 md-resolved.txt | 对候选地址的 ip 2 md 结果 |
05-key-methods.txt | 关键行与高频地址的有界摘要 |
06-token-methods.txt | MethodDef token 对应的类名和方法名 |
在一次实际分析中,某个 8 字节值在信号线程的原始栈上重复了 582 次,而且 ip 2 md 把它映射到同一个 JIT 方法。这时“582”才不再是普通数据巧合,而是同一返回地址被递归压入栈上数百次的强证据。
8、从方法名回到代码
8.1、检查代码
得到一个类似 GetPostTokenId 的方法名后,先搜本方法、所有重载、间接调用和委托回调。StackOverflow 并不一定是 A -> A,还可能是 A -> B -> C -> A、属性 getter 互相引用,或者遍历关系图时遇到了环。
一类常见缺陷可以抽象为:
| |
重点排查这个方法在哪些场景下可能出现递归,并且死循环了。这一步依然可以试用 AI 来进行分析。
8.2、区分根因、触发条件和暴露放大器
这三者容易被混在一起:
- 根因:遍历逻辑没有环检测、深度上限,也没验证下一步确实前进。
- 触发条件:某一组罕见数据或并发时序形成自环/互环。
- 暴露放大器:新版本改变调用频次、并发度、缓存或时序,让旧缺陷从几周一次变成每天数次。
生产出现问题时,立即做了回滚,回滚后正常,但“回退后频率降低”不能证明回退的代码就是根因,可能是两个版本之间的差异改动,让触发条件变的更容易了。
8.3、修复要点
关系图遍历优先改成迭代,显式维护 visited、深度和分支策略。下面是脱敏示例:
| |
在生产环境,异常消息不要输出完整业务对象。记录请求关联 ID、当前节点、下一节点、深度、已访问数和分支数即可,并进行频率限制。
8.4、必须覆盖的回归测试
| 用例 | 预期 |
|---|---|
正常链 A -> B -> C | 返回 C |
自环 A -> A | 立即报环,不再进入第二次遍历 |
互环 A -> B -> A | 第三步前报环 |
| 断链:节点为 null 或 ID 为空 | 返回可诊断错误 |
| 一个节点有多个后继 | 按业务规则明确选择,或拒绝歧义 |
| 链长恰好等于上限 | 成功,验证边界 |
| 链长超过上限 | 可控失败,不会耗尽线程栈 |
| 读取过程中关系被并发更新 | 使用快照/事务,或检测版本变化后重试 |
9、结语
这类事故最容易让人停在几个表象上:容器重启了、服务器内存还很多、/dumps 是空的、GDB 只有 ??、SOS 只显示 Unknown。
真正有效的方法是建立一条证据链:
Docker 事件确认退出原因 → core 时间与日志对齐 → 精确 Runtime/App/Rootfs 恢复现场 → 原始栈找重复地址 →
ip2md验证代码地址 → MethodDef token 还原方法 → 代码与数据触发条件回溯。
在这条链上,分析镜像解决的是“内网没工具”,精确镜像文件解决的是“库和符号不匹配”,原始栈扫描解决的是“StackOverflow 后托管栈已无法常规展开”。当这三层都对齐,一个看似只剩 ?? 的 core,仍然可以把问题定位到具体方法和数据结构。
整个过程是在 Codex 中一轮一轮沟通,Codex 给我提供了方法、脚本和操作步骤,我手动拷贝到内网执行,然后将结果截图给 Codex ,直到问题解决。
本文也是让 Codex 将整个对话过程整理为文章,我进行了少量的修改和调整。
评论
评论组件按需加载,不影响文章阅读速度。