0x00 背景
hdfs增加了一个native方法,打成了libhadoop.so这个动态库。需要分发到线上的各个Datanode上以便升级。在灰度分发到datanode时遇到了可复现的问题,即datanode进程肯定会core dump。分析core dump时产生的hs_err_pid.log文件后,发现最后的执行现场都是在执行native方法。怀疑和替换.so文件有关。
Google了一下,关键词:“如何动态替换.so文件”。确实能够解决问题,即我之前用的是cp -rf这种粗鲁的覆盖文件的手段,优雅的方法是mv+cp的方式。按照网络上的方法修改了分发脚本后,问题解决。
解决问题就结束了?这不是忘记了《码农翻身》里大佬的谆谆教诲了么?非常好奇为什么会有这种现象。网上的文章没有一个说清楚的。自己来吧。
本文不光会解释这个现象的原理,还会进行试验验证,还还还会提出一些自己问题(给自己挖坑)后面继续深挖。
Let's GO!!!
0x01 原理
首先需要一些关于inode的前置知识,更详细的内容放在本文最后的参考链接【1】【2】,有兴趣的朋友可以学习下。
这里简单介绍一下:
linux操作系统中,每一个文件都有对应的inode,里面有关于文件的一些信息,例如:文件的字节数、文件拥有者的User ID、文件的Group ID、文件的读、写、执行权限等、链接数,即有多少文件名指向这个inode、文件数据block的位置。总之,除了文件名以外的所有文件信息,都存在inode之中。
还要记住两个至关重要的知识点:
①移动文件或重命名文件(mv命令),只是改变文件名,不影响inode号码。
②打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。
也就是说mv操作是不改变已经加载到内存的文件的inode号的。
我们来看下cp和mv操作对文件的inode都有什么样的影响。
hadoop@dn1949:~$ touch test1 test2 && ls -i test1 test2
2174306 test1 2183331 test2 //新建的两个文件,前面的数字代表inode号
hadoop@dn1949:~$ cp test1 test2 && ls -i test1 test2
2174306 test1 2183331 test2 // cp覆盖,将test2覆盖成test1,但是test2的inode号还是原来的。
hadoop@dn1949:~$ mv test1 test2 && ls -i test2
2174306 test2 //将test1 mv 成test2,test2的inode号是test1的inode号。
hadoop@dn1949:~$ cp test2 test3 && ls -i test2 test3
2174306 test2 2183331 test3 //将test2 cp成之前并未存在的test3文件,test3使用新的inode号。
hadoop@dn1949:~$
总结:
cp命令
- inode号分配
- 如果目标文件不存在,分配一个未使用的inode号,在inode表中添加一个新项目;
- 如果目标文件存在,则inode号采用被覆盖之前的目标文件的inode号;
在目录中新建一个dentry,并指向步骤1中的inode。
把数据复制到block中。
mv命令
- 如果mv命令的目标和源文件所在的文件系统相同:
- 使用新文件名建立dentry(文件名 -> inode)
- 删除带有原来文件名的dentry; 【该操作对inode表没有影响(除时间戳),对数据的位置也没有影响,不移动任何数据。(即使是mv到一个已经存在的目标文件,新目录项指源文件inode,会先删除目标文件的dentry)】
- 如果目标和源文件所在文件系统不相同,就是cp和rm;
好了,前置知识差不多说完了,接下来是解释为什么cp -rf覆盖.so文件的方式会导致进程core dump。其实发生了如下事情:
①进程启动时,通过dlopen打开.so,内核会通过mmap把.so文件加载到进程的地址空间,对应了内存中的几个page。(这里我是查阅了jdk加载.so的源码,最后会使用dlopen这个库函数加载。关于dlopen的实现,我去看了glibc的源码,c语言知识浅薄再加上Clion这个IDE有时候没法跳转到函数,没找到调用mmap函数)
②在这个过程中loader会把so里面引用的外部符号例如malloc printf等解析成真正的虚存地址。
③我们执行cp -rf覆盖.so文件时会对.so进行truncate,当so被trunc时,kernel会把so文件在虚拟内的页清理掉。
怎么知道cp命令会truncate的呢?
strace cp test2 test3
使用strace追踪cp的系统调用过程,有如下:
fstat(3, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
open("test3", O_WRONLY|O_TRUNC) = 4
cp的实现是如果目标文件存在会先truncate清空目标文件内容,然后再把数据写到目标文件里。
顺带提一句,mv命令执行的系统调用是rename,不会截断文件。
④当运行到so里面的代码时,因为物理内存中不再有实际的数据(仅存在于虚存空间内),会产生一次缺页中断。
⑤Kernel从so文件中copy一份到内存中去。这时就会发生下面几种情况:
a.如果需要的文件偏移大于新的so的地址范围,就会产生bus error。(此处待深入)
b.如果so里面依赖了外部符号,但是这时的全局符号表并没有经过重新解析,当调用到时就产生segment fault。(我遇到的线上问题就是属于这种情况)。
c.如果so里面没有依赖外部符号,程序侥幸可以继续运行。 (后续进行了第二次测试验证,发现如果程序启动时加载了最新的.so,那么即使cp -rf覆盖相同的内容,也不会导致程序core dump)
0x02 实验验证
验证目标:
mmap加载到内存中的文件,如果被cp -rf覆盖后会产生缺页中断。
测试方案:
- 创建1个文件test.txt,用mmap方法加载到内存中。
- 分别使用mv + cp 以及 cp -rf 覆盖的方式,进行替换test.txt
- 分别观察缺页中断的次数,使用命令:sudo perf stat -e faults -p 进程id
预期结果:
- 使用mv+cp的方式,进程不会产生缺页中断。
- 使用cp -rf覆盖的方式,进程会产生缺页中断。
实验程序代码如下:
主要参考了mmap手册中的demo,稍加修改,增加了一个无线循环,读映射到内存中文件。
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main (int argc, char **argv)
{
int fd, nread, i;
struct stat sb;
char *mapped;
if ( argc <= 1 ) {
printf("%s: Need file path! \n",argv[0]);
exit(-1);
}
/* 打开文件 */
if ((fd = open (argv[1], O_RDWR)) < 0) {
perror ("open");
}
/* 获取文件的属性 */
if ((fstat (fd, &sb)) == -1) {
perror ("fstat");
}
/* 将文件映射至进程的地址空间 */
if ((mapped = (char *) mmap (NULL, sb.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) == (void *) -1) {
perror ("mmap");
}
/* 映射完后, 关闭文件也可以操纵内存 */
close (fd);
//printf ("%s", mapped);
/* 修改一个字符,同步到磁盘文件 */
//int i = 0;
while (1) {
char ch = mapped[0];
//mapped[0] = i;
sleep(1);
}
if ((msync ((void *) mapped, sb.st_size, MS_SYNC)) == -1) {
perror ("msync");
}
/* 释放存储映射区 */
if ((munmap ((void *) mapped, sb.st_size)) == -1) {
perror ("munmap");
}
return 0;
}
编译运行(我的文件名叫testMmap.cpp):
g++ testMmap.cpp -o testMmap
./testMmap test1.txt
实验结果
一、mv + cp的方式
启动程序后,使用sudo perf stat -e faults -p 进程id 监控进程的缺页中断情况。
程序运行起来后,我们什么也不操作的话,是没有page fault的。因为我们的程序就是特意这么编写的,为了排除其他因素的干扰。
接着使用mv命令把mmap的文件test1.txt重命名成test2.txt。
然后用cp命令把zhanghaobo/test1.txt 复制到当前目录下。
然后来看缺页中断的结果:
0次,符合预期。
二、cp -rf覆盖的方式
在进行cp -rf的实验之前,我们需要停止程序。(如果不停止的话,需要把下面cp -rf 的被替换文件改成第一个实验里的test2.txt,因为第一个实验里我们进行了mv操作)
首先我们来看cp -rf覆盖的方式。cp -rf 一个同名文件。
执行cp -rf。
可以从下图发现确实有缺页中断产生。
所以这也就验证了使用直接覆盖动态库的方式会因为truncate文件导致进程缺页中断,后发现覆盖后的.so中依赖了未解析的符号(比如调用了新的函数等),就会导致进程core dump。
参考资料
【1】http://www.ruanyifeng.com/blog/2011/12/inode.html
【2】https://developer.aliyun.com/article/6371
【3】https://www.gnu.org/software/libc/
【4】https://man7.org/linux/man-pages/man2/mmap.2.html