崩溃堆栈还原技术揭秘

38次阅读

共计 4993 个字符,预计需要花费 13 分钟才能阅读完成。

0x00 前言

当应用出现崩溃的时候,程序员的第一反应肯定是:在我这好好的,肯定不是我的问题,不信我拿日志来定位一下,于是千辛万苦找出用户日志,符号表,提取出崩溃堆栈,拿命令开干,折腾好一个多小时,拿到了下面的结果:


addr2line -ipfCe libxxx.so 007da904 007da9db 007d7895 00002605 007dbdf1

logging::Logging::~Logging() LINE: logging.cc:856

logging::ErrLogging::~ErrLogging() LINE: logging..cc:993

base::internal::XXXX::Free(int) LINE: scoped____.cc:54

base::___Generic<int, base::internal::_____loseTraits>::_____sary() LINE: scoped_______.h:153

base::___Generic<int, base::internal::_____loseTraits>::_____eric() LINE: scoped_______.h:90

如果是接入了岳鹰全景监控平台,场景就完全不一样了。测试同学:发来一个链接,附言研发哥哥,这是你的 bug,请注意查收。研发哥哥:点开链接,就可以在平台看到这条崩溃信息啦,如下图:

那么问题来了,岳鹰上有这么多的应用版本,再加上海量的日志,对于 Native 崩溃,总不能每个崩溃点都用 addr2line 或者相关的命令去符号化吧?
岳鹰的符号化系统正是为了解决该问题而设计。岳鹰最初上线的版本 1.0,支持同时符号化解析数量有限,对 iOS 符号化时依赖 Mac 系统,不支持容器化部署,消耗机器资源较多。为了更好的满足用户业务需求,岳鹰在年初启动了 2.0 版本的改造,并且制定以下目标:

  • 同时解析不限数量的符号表
  • 提升符号化的效率
  • 解除 Mac 系统依赖,支持全容器化部署

那这样一个分布式的符号化系统该如何设计呢?接下来小编就来详细介绍下。

0x01 方案的选择

结合当前系统设计以及业界常见方案,我们有以下几条路可以走:

  1. 岳鹰 1.0 方案,用大磁盘,高 CPU 性能的机器搭建符号化机器,符号文件存放到磁盘,需要符号化时再调用 addr2line;
  2. 建立一个中央存储,把符号文件上传到中央存储,符号化机器需要符号化的时候再过去拉,然后用 addr2line 符号化;
  3. 把符号信息按 key-value 方式提取出来,存入 hbase 或者其它中间件,符号化时通过类 sql 查询实现。

结合岳鹰 2.0 的目标,我们对三个方案进行对比:

方案 1:符号文件上传倒是很快,如果需要高可用,还需要镜像一份到备机,且在做 addr2line 的时候,会带来高内存及高 cpu 的占用,而且不支持动态扩容,安全性也几乎没有,拿到机器就拿到了源码;

方案 2:符号文件存放于中央存储,做好备份机制后,能保障文件不会丢失,但机器在符号化时,都需要去中央存储拉符号文件,之后的处理同方案 1,查询效率不高,而且安全性也不高;

方案 3:在符号入库时,把符号信息按 key-value 方式提取出来,然后加密存入 hbase,这里要解决符号表全量导出及入库的速度及空间问题。

结合岳鹰 2.0 目标,我们对日志处理的及时性,可扩展性,安全性,以及海量版本同时解析的要求,我们选择了方案 3。下面我们先给大家简单介绍下原理,再深入看看选择方案 3 要解决哪些问题。

0x02 原理(大神请忽略这一节)

国际惯例,我们先来了解一下原理,符号表是什么?符号表是记录着地址或者混淆代码与源码的对应关系表。下面我们分别用一个小 demo 程序来讲解符号表及符号化的过程。

0x02-1 iOS-OC、Android-SO 符号化原理

a. 示例源码:


int add(){

int a = 1;

a ++;

int b = a+3;

return b;

}

int div(){

int a = 1;

a ++;

int b = a/0;                // 这里除 0 会引发崩溃

return b;

}

int _tmain(int argc, _TCHAR* argv[]){add();

sub();

return 0;

}

b. 对应符号表,这里简化了符号表,没带行号信息


0x00F913B0 ~ 0x00F913F0    add()

0x00F91410 ~ 0x00F91450    div()

0x00F91A90 ~ 0x00F91ACD    _tmain()

c. 现有一崩溃堆栈


0x00F9143A

0x00F91AB0

d. 进行符号化


0x00F9143A    div()    // 查找符号表,地址 0x00F9143A 的符号名,在 0x00F91410 ~ 0x00F91450 范围内

0x00F91AB0    _tmain() // 查找符号表,地址 0x00F91AB0 的符号名,在 0x00F91A90 ~ 0x00F91ACD 范围内

0x02-2 Android-Java 符号化原理

a. 示例源码:


package com.uc.meg.wpk

class User{

    int count;

    UserDTO userDto;

    UserDTO get(int id){...}

    int set(UserDTO userDto){...}

}

class UserDTO{

    int id;

    String name;

}

b. 符号表


com.a.b.c.d -> com.uc.meg.wpk.User

    int count -> a

    com.uc.meg.wpk.UserDTO -> b

    com.uc.meg.wpk.UserDTO get(int) -> c

    int set(com.uc.meg.wpk.UserDTO) -> d

com.a.b.c.e -> com.uc.meg.wpk.UserDTO

    int id -> a

    String name -> b

c. 现有一崩溃堆栈


com.a.b.c.d.d(com.a.b.c.e)

d. 进行符号化


// 符号化 com.a.b.c.d.d(com.a.b.c.e)   

// 查找 com.a.b.c.d,命中 com.uc.meg.wpk.User

// 查找 com.uc.meg.wpk.User.d 命中 set()

// 查找 com.a.b.c.e,命中 com.uc.meg.wpk.UserDTO

// 符号化结果为 com.uc.meg.wpk.User.set(com.uc.meg.wpk.UserDTO)

0x03 新的难题

选择方案 3 后,主要瓶颈在符号表上传之后处理,这里主要工作是要把符号表转换为 key-value,然后再写入 hbase。现在主流的 app 开发有 android 的 java 及 C ++,iOS 的 OC,我们下面主要讨论这三种符号。因为 android 的 java 符号化有 google 的开源工具支持,这里就不再展开。OC 因为是 iOS 系统,封闭系统,标准统一,上架 AppStrore 的应用,只用 XCode 进行编译,没有各种定制的需求。我们原来有一个 OC 实现的符号表 kv 提取程序,但是只能用于 OSX 系统,不便于线上布署,所以我们选择了用 java 重写了提取符号 kv 的功能。但是对于 Android 的 C ++ 库 so 符号表,即 ELF 格式,存在着各种版本,各种定制下不同的编译参数,会大幅增加用 java 重写的成本,所以我们使用了 Java 跟 C ++ 结合的方式去实现 ELF 的符号表 kv 的提取,先用 Java 程序把 ELF 的基础信息,地址表读取出来,然后再用 addr2line 去遍历这个地址表,然后再把结果存入 hbase,这个为 100% 的符号化成功率打下基础。

0x03-1 addr2line 的问题

改进前后的对比

当然,这个 addr2line,是要经过改造才能达到我们的要求,原来的 addr2line 是给开发者以单条命令去使用,不是给程序做批量查询的,每次查询都是要把整个 ELF 文件加载到内存,像 UC 内核,还有一些游戏的 so 文件,大小要到几百 M 的级别,每个 addr2line 进程都要一份独立的内存。假设一个 500M 的 so 符号,一台 64 核的机器,假如用 60 核去 100% 跑 addr2line,加上其它开销,它就需要 35G 的内存。面对这么高的 cpu 和内存占用,而且是一个较低的 QPS,单核大约 100QPS,我们也尝试去优化 addr2line 的 binutils 中的 bfd 部分,但是最终的接口都是调用系统内核的,这条路,短期好像走不通。面对这样的性能问题,期间也多次尝试用 Java 去重写这部分逻辑,但是最终结果只能实现与 addr2line 的 90% 匹配度,而且还有很多未知的兼容性问题,最后还是选择了改造 addr2line,改造点主要有以下三点:

  • 从文件读取地址表,使用批量请求去 addr2line,减少 bfd 初始化的次数,因为这个过程中,bfd 接口在调用一些特定的地址转换后,会导致 qps 降到个位数,需要重启进程才行;
  • 减少额外的内存开销;
  • 支持多进程,多容器分布式任务调度,支持动态扩缩容,提高资源利用率。

改造后,单核的 QPS 大约提升到 800QPS,上面举的 500M 的 so 符号的例子,大约需要 15 分钟,基本能满足我们的需求。

0x03-2 存储的问题

解决完提取的问题,接下来就是存储的问题。符号表都是经过精心设计的高度压缩的数据结构,我们通过上面的方案把它提取出 kv 的格式,容量上增加了 10+ 倍,而且很多信息都是重复的,如函数名,文件名这些,虽然空间对于 hbase 来说不是什么问题,但是在追求极致的面前,我们还可以再折腾折腾。前面提到我们因为要考虑数据的安全性,需要把存入 hbase 的数据做加密,所以不能直接用 hbase 本身的压缩功能,要求在加密前先做好压缩,如果是按行压缩再加密,总体的压缩比不会太高,我们可以把 00006740~000069eb 这一段当成一个大段,把它们压缩在一起再加密,这样因为重复信息较多,压缩比会很高,最终的体积可以缩小 5 + 倍,相当于只是比原始符号表大 3~4 倍。hbase rowkey 的设计,因为后面的查询会需要用到 scan,我们把符号表 kv 的结束地址作为 rowkey 的一部分,至于为什么这么设计,往下读,你就明白了。

0x03-3 查询的问题

根据 0x01 原理,对 hbase 的查询,需要 get,scan 的支持,get 的话相对简单,直接通过 rowkey 命中就好了,适用于 java 符号化的场景,对于 C ++/OC 的符号化,就需要 scan 的支持,因为地址是一个范围,不能用 get 直接命中,下面用伪代码举例说明 scan 的流程:


//1. 扫描 libxxx.so 符号,地址范围 0x00001234 ~ 0xffffffff,只取一条结果

// 这里利用了 scan 的特性,我们存的 rowkey 是符号的结束地址,所以扫描出的第一个,// 就是最接近 0x00001234 的一个符号

raw = scan("libxxx.so", 0x00001234, 0xffffffff, limit=1);

//2. 解密,解压,判断有效性预处理

data = pre(raw);

//3. 精确定位地址,根据 0x04- 2 的打包存入,再做切割拆分

result = splitData(data);

旧系统我们只用了应用级的缓存,每次重启缓存就会丢失,为了减小 hbase 的压力,我们增加一级分布式缓存,使用 redis 作为缓存,进一步减少了末端的查询 QPS。

0x03-4 如果保证 100% 的符号化成功率

我们知道,如果符号化失败,就会出现不一样的崩溃点,这样就不能把这些崩溃点聚合在一起,会把一些严重的问题分散掉,同时会产生很多新的崩溃点,导致开发,测试无法分辨真实的崩溃情况,我们使用以下技术保障成功率:

  • 高并发,低延迟的符号化查询服务,保障解析效率,防止超时出现符号化失败的情况;
  • 多级缓存保障,减少 hbase 的 scan 操作;
  • 使用原生 addr2line 提取符号 kv;
  • 重试机制。

0x04 总结

0x04-1 符号化系统的核心能力

通过几个平台的符号化反能力对比,我们可以看到岳鹰 2.0 取得的阶段性成果。

0x04-2 运行效果的提升

0x05 欢迎免费试用

岳鹰为阿里集团众多使用 UC 内核的 app(如手淘,支付宝,天猫,钉钉,优酷等)提供内核 so 的崩溃符号化功能,实现了 Java,Native C++ 的质量监控完整闭环,并在 Native C++ 上的支持上明显优于其它竞品,开发者能快速地还原现场并找出问题,同时整个系统支持动态扩缩容,为更多业务接入打下了坚实的基础。更多功能,欢迎来岳鹰全景监控平台平台体验

正文完
 0