1.服务器环境以及配置
【机型】
处理器: | HUAWEI,Kunpeng 920 |
内存: | 400G+ |
【内核版本】
4.19.90-23.18.v2101.ky10.aarch64
【OS镜像版本】
银河麒麟高级服务器操作系统V10-SP1-0711-arm
【第三方软件】
docker
2.问题现象描述
一台k8s服务器里面的容器java程序启动shell脚本执行解压unzip命令,进程一直处于sleep状态,没有执行解压,一直卡着。
3.问题分析
通过gdb查看,可看到unzip进程栈为:
可看到阻塞在fgets调用read上,此时read通过系统调用到了内核,所以推测为阻塞在内核阶段。
此时查看进程内核栈为:
根据内核栈,可知unzip进程卡在了读取管道数据上,此状态可以用以下方式进行模拟:
即直接cat 标准输入,则可直接模拟问题,至此,直接原因可明确:unzip进程卡在了读取管道数据上。
但问题根本原因,以及正常是怎样的流程,需要继续分析:
查看现场文件描述符信息如下:
查看确认0,1,2描述符继承自父进程。
通过cat测试相关描述符,发现描述符内容很多。
本地java启动shell脚本启动unzip可看到同样的描述符继承关系:
java程序如下:
public static void main(String[] args) throws Exception {
String command = "sh a.sh logfile.zip .";
Process process = Runtime.getRuntime().exec(command);
process.waitFor();
process.destroy();
int i = process.exitValue();
System.out.println("子进程退出值:"+i);
}
a.sh脚本为:unzip -o $1 -d $2
但本地无法模拟出现场情况,本地测试,cat 0,2管道都无内容,1有解压时的标准输出内容,与现场管道中有大量内容不同。同时,本地对unzip进程fgets函数做跟踪,无法捕捉到fgets调用,也没有捕捉到pipe_read操作。本地的所有read操作都是对文件描述符3的操作:
即从压缩文件中读数据。
推测无法复现原因与现场的环境相关,本地可能无法完全模拟现场状态,导致无法追踪到对应调用。
尝试从其它方向获得更多有用信息。
根据fgets说明:
fgets() 的原型为: char *fgets(char *s, int size, FILE *stream);
它的功能是从 stream 流中读取 size 个字 符指针变量 s 所指向的内存空间。它的返回值是一个指针,指向字符串中第一个字符的地址。
从函数介绍,结合前面信息可知unzip阻塞在尝试从管道读取若干数据。
本地使用head模拟读取若干数据后返回操作,如下:
可看到在未获取到对应数据之前,进程将一直阻塞在pipe_wait处。直到获取到对应数据,则返回。
由于本地无法模拟,所以无法弄清楚,unzip进程在读管道里的什么数据,以及这些数据从哪里来。
现场可尝试搭建一个测试环境,安装glibc的debuginfo包,对fgets进行调试,即可对进程核外调用有更准确的掌握,如从哪个描述符取多少数据,以及谁在往里写数据。
同时我们对java的管道规范进行了梳理,发现:
JDK中有相关说明:
By default, the created subprocess does not have its own terminal or console. All its standard I/O (i.e. stdin, stdout, stderr) operations will be redirected to the parent process, where they can be accessed via the streams obtained using the methods getOutputStream(), getInputStream(), and getErrorStream (). The parent process uses these streams to feed input to and get output from the subprocess. Becau se some native platforms only provide limited buffer size for standard input and output streams, failure to promptly write the input stream or read the output stream of the subprocess may cause the subprocess to block, or even deadlock.
从JDK的说明中可以看出两点:
1.子进程的标准I/O已经被重定向到了父进程。父进程可以通过对应的接口获取到子进程的I/O
2.如果系统中标准输入输出流使用的bufffer大小有限,所有读写时可能会出现阻塞或死锁。
结合现场cat描述符异常,基本可确认现场标准输入输出流异常。
基于此,在相关编程中,java编程中推荐了多种处理方法:
1.不断的读取消耗缓冲区的数据,以至子进程不会挂起,如:
public class ProcessUtils {
public static void main(String[] args) throws IOException, InterruptedExcption{
String command = "unzip xxxx";
Process process = Runtime.getRuntime().exec(command);
readStreamInfo(process.getInputStream(), process.getErrorStream());
int exit = process.waitFor();
process.destroy();
if (exit == 0) {
log.debug("子进程正常完成");
} else {
log.debug("子进程异常结束");
}
}
/**
* 读取RunTime.exec运行子进程的输入流 和 异常流
* @param inputStreams 输入流
*/
public static void readStreamInfo(InputStream... inputStreams){
ExecutorService executorService = Executors.newFixedThreadPool(inputS
for (InputStream in : inputStreams) {
executorService.execute(new MyThread (in));
}
executorService.shutdown();
}
}
public class MyThread implements Runnable {
private InputStream in;
public MyThread(InputStream in){
this.in = in;
}
public void run() {
try{
BufferedReader br = new BufferedReader(new InputStreamReader(in,
String line = null;
while((line = br.readLine())!=null){
log.debug(" inputStream: " + line);
}
}catch (IOException e){
e.printStackTrace();
}finally {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
- 添加timeout超时:
public static int executeProcess(int timeout)throws IOException, InterruptedException, TimeoutException {
Process process = Runtime.getRuntime().exec(command);
Worker worker = new Worker(process);
worker.start();
try {
worker.join(timeout);
if (worker.exit != null){
return worker.exit;
} else{
throw new TimeoutException();
}
} catch (InterruptedException ex) {
worker.interrupt();
Thread.currentThread().interrupt();
throw ex;
}
finally {
process.destroy();
}
}
现场同时也发现java程序所在docker容器内存已被占满,本地模拟内存占满测试,并未复现,同时本地测试中,始终没有捕捉到unzip的fgets及pipe_read动作。所以内存占满可能并非为唯一关键因素。
4.问题总结
通过测试分析,可知问题的直接原因为java调用unzip进程阻塞在了读取管道数据调用上。但由于没有现场代码及环境,使用简单的本地java模拟无法复现对应调用流程,所以无法对其可能的触发流程做详细梳理,如果现场有条件,可安装unzip及glibc的debuginfo包,通过gdb对相关进程的fgets操作管道的流程进行捕捉,及梳理。基于此,可确认正常的调用流程。
同时现场可进一步检查unzip的0,1,2管道的情况,通过cat管道内容到文件的方式保存管道内容进行分析,如果仅仅只作为unzip的标准输入输出及错误输出,不应该有很多内容才对,是否在java编程中做了更多的操作,需梳理下java的相关调用。结合unzip操作管道的正常流程,来最终确认管道异常原因。
针对java编程中父子进程管道通信可能存在的潜在问题,提供了两个可能的优化方法:
- 不断的读取消耗缓冲区的数据,以至子进程不会挂起
- 添加timeout超时,防止子进程异常阻塞导致父进程异常无法恢复。