在我们调试程序时经常会使用到core dump功能,使用调试器(如gdb)分析其产生的Core Dump文件(以下称"core文件"),对于排查问题、定位bug堪称无往不利的利器。当前容器技术使用愈加普遍,线上大量业务使用容器技术部署,那我们的业务进程在容器环境下core文件是如何产生、与在宿主机中有什么不同呢?本文针对这个问题简略说明,抛砖引玉。

 
 

什么是Core文件

Core文件是当一个进程在收到某些信号后终止时产生的文件,其中包含进程终止时刻进程内存的镜像。我们可以使用gdb从该镜像中观察进程终止时处于什么状态,用于追踪排查定位问题。

如下示例,其中/usr/share/core_pipe/test是crash程序,core.29873就是core文件,其中29873是crash进程的PID。

# gdb /usr/share/core_pipe/test core.29873GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-gitCopyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from /usr/share/core_pipe/test...done.
[New LWP 34]

warning: .dynamic section for "/lib64/ld-linux-x86-64.so.2" is not at the expected address (wrong library or version mismatch?)warning: Could not load shared library symbols for /lib64/libc.so.6.
Do you need "set solib-search-path" or "set sysroot"?
Core was generated by `./test'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000556039e756aa in main () at main.c:7
7     printf("this is null p %d \n", *intp);
(gdb)
 
 

宿主机进程Core Dump

在讲容器进程Core Dump之前,我们先来简单回顾下宿主机进程Core Dump在什么条件下产生。这里的宿主机泛指物理机和虚拟主机,宿主机进程特指运行在系统主机Initial Namespace的进程,

容器进程特指运行在容器Namespace中的进程。

前文说到当进程收到某种信号才会产生,那么什么才是"某种信号"呢?这些信号大约有十多个,这里只列出我们常见比较熟悉的几个:

- SIGQUIT  数值2  从键盘输入 Ctrl+'\'可以产生此信号

- SIGILL   数值4  非法指令

- SIGABRT  数值6  abort调用

- SIGSEGV  数值11 非法内存访问
- SIGTRAP  数值5  调试程序时使用的断点

其中SIGSEGV应该是我们最常接触的,尤其是使用C/C++程序的同学更是常见。

main.c
#include <stdio.h>int main(){  int *p = NULL;  
  printf("hello world! \n");
  printf("this will cause core dump p %d", *p);
}

使用下面命名编译即可产生运行时触发非法内存访问SIGSEGV信号,其中-g选项是编译添加调试信息,对于gdb调试非常有用。

# gcc -g main.c -o test

除了上述产生信号的方法外,使用我们经常使用的kill命令可以非常方便的产生这些信号,另外还有gcore命令可以在不终止进程的前提下产生core文件。

那么只要进程收到这些信号就一定会有core文件吗?显然不是,linux在这方面提供了相关配置。那么这些配置都有哪些呢?

1、设置Core文件大小上限

ulimit
# ulimit -c 0 // 0表示不产生core文件# ulimit -c 100 // 100表示设置core文件最大为100k,当然可以根据自己需要设置,注意设置单位是KB# ulimit -c unlimited // 不限制core文件大小

使用上述命令设置core文件大小只对当前shell环境有效,系统重启或者退出当前shell设置就会失效,恢复默认设置。

若想永久生效可以把该shell放到/etc/profile文件中,系统重新启动初始化环境时会执行其中的命令,该设置就会在全局范围内生效,达到永久生效的效果。也可以使用 source /etc/profile命令立即全局生效。

# echo "unlimit -c unlimited" >> /etc/profile // 配置添加到/etc/profile中# source /etc/profile // 立即生效

2、设置Core文件存储目录

# echo "/var/core-dir/core-%e-%p-%t" >  /proc/sys/kernel/core_pattern

该命令可以控制core文件保存位置和文件名格式。注意需要使用root权限执行,并且存储路径必须是绝对路径,不能是相对路径

其中%e表示添加用户程序名称,%p表示添加程序进程PID,%t表示添加产生core文件时的时间戳,还有其他一些非常有用的格式,可以参阅CORE(5) 文档。

这样修改后系统重启后也会消失,同样也有永久生效的办法

修改/etc/sysctl.conf文件在其中修改或者添加

/etc/sysctl.conf
kernel.core_pattern = /var/core-dir/core-%e-%p-%t

然后执行下面命令配置立即生效,这样系统重启后配置依然有效

# sysctl –p /etc/sysctl.conf

3、宿主机Core文件小结

宿主机产生core文件可以使用如下步骤

1. ulimit -c 命令设置core文件大小2. 修改core_pattern选项配置core文件存储目录

具备上述两个条件后,当进程在一定条件core后,就会存储core文件到我们配置的目录中。

 
 

Core文件产生流程

大概说一下从进程出现异常到生成core文件的流程,不会涉及太多Linux系统实现细节,具体细节可以参考相关文档和linux内核源码。

  1. 进程运行时发生一个异常,比如非法内存地址访问(即段错误),相应硬件会上报该异常,CPU检测到该异常时会进入异常处理流程,包括保存当前上下文,跳转到对应的中断向量执行入口等

  2. 在异常处理流程中如果判断该异常发生时是处于用户态,则该异常只会影响当前进程,此时向用户态进程发送相应的信号,如段错误就会发送SIGSEGV信号

  3. 当用户态进程被调度时会检查pending的信号,如果发现pending的信号是SIG_KERNEL_COREDUMP_MASK中的一个,就会进入core文件内容收集存储流程,然后根据配置(core_pattern等)生成core文件

 
 

容器进程Core Dump

那么如果我们要收集在容器里运行的进程的core文件应该如何设置呢?

答案是上述宿主机针对core文件的设置对容器中的进程依然有效。

众所周知,宿主机上所有的容器都是共享系统内核的,/proc/sys文件下只有一小部分支持namespace隔离,而core_pattern恰巧不支持隔离的,所以无论是从宿主机还是容器里修改core_pattern,最终修改的是同一个设置,并且全局生效,不管是对宿主机还是对容器都是有效的。

一般情况下每个容器都有自己的mount namespace,其中的文件系统与宿主机和其他容器相隔离,那么在core_pattern指定的core文件存储目录是容器中的文件目录还是宿主机中呢?不妨推测一二,刚才我们已经说过这个core_pattern是全局生效,如果该目录是针对某个容器的文件目录,那么肯定是不合理的,因为如果宿主机上进程Core Dump时就会找不到对应的目录,无法保存。

实际上有效的core_pattern中的目录必须是宿主机中的绝对目录,更准确的描述是宿主机Initial Namespace中的绝对路径。

另外一个问题是,每个容器都有自己pid namespace,我们再core_pattern中设置的获取crash进程的各种信息比如PID,可执行文件名,是容器namespace中的还是宿主机namespace中的呢?从相关文档和实验得知,可以同时获取crash进程在容器中的PID(通过%p格式指定)和在宿主机Initial Namespace中的PID(通过%P格式指定),可执行文件名称(通过%e或%E格式指定)是容器的namespace中的。

之所以造成上述情况,根本原因是Core Dump流程中内核进程最后负责处理core文件存储的,而内核进程运行在宿主机Initial Namespace中,实际上所有的容器进程在宿主机Initial Namespace都有映射,对内核来讲,宿主机进程和容器进程可以统一处理,并没有本质区别。

1、使用管道解决容器进程Core Dummp问题

上文中我们得知了容器进程core文件产生的方法,但是有一个问题就是上述方法的设置是对宿主机和容器内所有的进程都生效的。无法针对特定容器进程特定设置。比如说我们希望宿主机进程core文件保存到/var/crash目录,而对容器的core文件保存在/var/container/crash目录,或者我要限制某个容器产生core文件的总存储大小,而不是单个core文件的大小;如果我们做一个服务平台对其他用户开放Core Dump功能的话,我们肯定还希望获取一下crash进程的其他额外信息比如进程当前环境变量、当前用户、当前进程有效UID和GID、任务名称属性;如果我们希望针对core事件进行统计分析的话,可能还需要各种回调通知等等操作。

显然上述简单的设置core文件存储目录的方法无法满足我们的需求的,那么我们还有另外一个选择,就是使用linux的piping技术转储core文件。

从linux内核版本2.6.19之后,内核就开支支持在/proc/sys/kernel/core_pattern文件中指定一个管道程序来实际处理core文件存储。core文件内容会作为该管道程序的标准输入传输给管道程序,管道程序就接管了接下来的core文件内容的所有处理。如下设置可以使用piping技术转储core文件

# echo "|/usr/share/core_pipe/core_pipe core -H=%h -p=%p -i=%i -s=%s  -c=%c > /proc/sys/kernel/core_pattern
# cat /proc/sys/kernel/core_pattern
|/usr/share/core_pipe/core_pipe core -H=%h -p=%p -i=%i -s=%s  -c=%c

其中/usr/share/core_pipe/core_pipe是我们的管道程序,需要注意的是必须以|开发, |之后必须紧接管道程序的路径,没有空格。当有进程core时,就会调用该管道程序进行处理。

我们可以开发自己的管道处理程序,从管道程序启动的参数获取crash的进程信息,从管道程序的标准输入获取core文件的内容。

我们现在知晓该管道程序什么时候被调用(进程Core Dump时),那么管道程序是由谁来调用呢?

既然管道程序是我们自己开发的,我们就可以获取管道程序的父进程是谁,也就是被谁调用的,通过实验我们可一知道父进程的PID是2,当我们再看该进程的父进程是谁:

# ps -ef -q 2UID         PID   PPID  C STIME TTY          TIME CMD
root          2      0  0 Jan20 ?        00:00:00 [kthreadd]

进程PID2的父进程是PID 0,而PID 0代表的是linux系统内核idle进程,Linux系统中共有三个特殊进程,分别是idle(PID 0), init(PID 1), kthreadd(PID 2),而kthreadd是所有内核进程的父进程,也就是说我们的管道程序是作为内核线程在运行的,运行在内核态,并且在宿主机Initial Namespace中以root用户身份运行,不在任何容器内。

2、Socket Activation应用到容器进程Core Dummp

上文说了管道程序运行在内核态,而且是在宿主机的Initial Namespace中运行,容器的各种限制对其不起作用,比如core文件大小有可能超过容器的硬盘空间限制。当然我们管道程序可以通过crash进程的PID拿到crash进程的容器namespace以及各种cgroup限制,然后针对性处理。这样显然对容器极有侵入性,代码写起来也不够优雅。如果处理core文件存储程序在容器中运行,就能较优雅的解决好这个问题。管道程序已经作为内核线程运行在宿主机的Initial Namespace了,虽然有办法可以动态的加入和退出某个namespace和cgroup,但是考虑的边界条件多,易出错,并不优雅。

如果管道程序能够和容器内某个程序进行交互,可以解决上述问题,同一个宿主机进程通信的方式有很多,比如共享内存,管道,消息队列等。但是这里的两个程序是分布在不同的namespace中,而且彼此并不知道什么时候可以交互,我们为了低概率的core文件长时间让容器内某个进程空跑占用资源吗?那么socket activation技术可以用来解决这个问题。

socket activation并不是一种新技术,其技术理念和原理早就被应用到Linux和MacOS中,关于socket activation技术原理细节又是需要另一篇的长篇大论,这里暂且不再详述,简单来说,就是由系统init进程(对于目前大多数linux系统来说是systemd)来为普通应用进程监听特定socket,此时应用进程并未启动,当有连接到达该socket后,由init进程接管该连接并跟进配置文件启动相应的应用进程,然后把连接传递给应用进程来处理,主要好处是当没有连接到达时,应用进程无需常驻后台空跑耗费系统资源。非常适合像Core Dump这种低频服务。

我们可以设置一个unix socket来把管道程序的文件描述符传递到容器内进程,完成传递后, 管道程序就可以退出,由容器内进程处理core文件的存储。

下面是一个socket activation示例,其中/usr/share/core_pipe/core_pipe是我们的core 文件处理程序, /run/core_pipe.socket是我们unix socket文件,存在容器中,该文件我们在Initial Namespace中的管道程序可以通过/proc/${crash pid}/root/run/core_pipe.socket拿到,然后与之交互。

core_pipe-forward.socket
# 此为Unit文件,保存内容为文件到 /etc/systemd/system/core_pipe-forward.socket
[Unit]
Description=Unix socket for core_pipe crash forwarding
ConditionVirtualization=container

[Socket]
ListenStream=/run/core_pipe.socket
SocketMode=0600Accept=yes
MaxConnections=10Backlog=5PassCredentials=true[Install]
WantedBy=sockets.target复制代码
# 此为service文件,保存内容到 /etc/systemd/system/core_pipe-forward.service
[Unit]
Description=Core Pipe crash forwarding receiver
Requires=core_pipe-forward.socket

[Service]
Type=oneshot
ExecStart=/usr/share/core_pipe/core_pipe

core_pipe-forward.socket

执行下面命令使得socket生效

# systemctl enable core_pipe-forward.socket
# systemctl start core_pipe-forward.socket
# systemctl status core_pipe-forward.socket

上述命令如果是在容器内的init进程不是systemd情况下会出错,大多数情况下容器内的init进程并不是systemd,此时可以退一步使用容器内常驻进程的方式来实现core文件的处理。