Linux环境Runtime如何运行子进程?

在Linux服务器上通过Java的Runtime运行命令时可能会报错:Cannot run program "pwd": error=13, Permission denied

在制作Linux服务器上的产品安装包时遇到了这个错误,各种搜索资料都找不到解决办法,经过对比分析确定最后的问题后,顺藤摸瓜找到了一些资料。

1. 问题产生的原因

因为要制作Linux产品安装包,在产品安装包中包含了内置的java环境,这个环境是在Windows的WSL环境中,通过jdk-17.0.8_linux-x64_bin.tar.gz这个完整的jdk包执行 jlink --add-modules ALL-MODULE-PATH --output jres 导出了一份包含所有模块的 jres 环境。

将这个包上传到linux后,解压jres,然后chmod +x jres/bin/*,给所有可执行程序增加执行权限,然后在通过 jres/bin/java -jar xxx.jar 启动Java应用。

安装程序中有很多功能是通过 Runtime.getRuntime().exec("pwd") 方式执行的,不管执行什么命令都会遇到Cannot run program "pwd": error=13, Permission denied的错误。

2. 定位问题

基于上面的错误信息查找了很多资料,都没有解决问题。

此时为了排查问题,我在Linux安装了一个新的jdk,然后编译运行发现一切正常。

jdk-17.0.8_linux-x64_bin.tar.gz 放到 Linux,通过 jlink 打包 jres后,这个jres环境也一切正常,此时的问题可能和我在Windows操作jres后丢失默认的linux权限导致的,对比了几个目录,逐个增加可执行权限比对后最后发现了问题所在。

除了 jres/bin 下面有可执行程序外,在 jres/libs 下面也有几个缺少执行权限的程序,对比发现是 jspawnhelper 影响了 Runtime.getRuntime().exec("pwd") 方式的执行。

3. jspawnhelper 是什么?

设计原因:https://mail.openjdk.org/pipermail/core-libs-dev/2018-September/055333.html

这个程序的源码为 jspawnhelper.c,在 ProcessImpl_md.c 的注释中说明了 jspawnhelper 的作用。

部分注释如下:

1
2
3
4
5
6
When starting a child on Unix, we need to do three things:
- fork off
- in the child process, do some pre-exec work: duping/closing file
descriptors to set up stdio-redirection, setting environment variables,
changing paths...
- then exec(2) the target binary

经过Claude翻译后的完整内容如下。

当在Unix上启动一个子进程时,我们需要做三件事:

  • 调用fork分支出子进程
  • 在子进程中,做一些exec之前的准备工作: 复制/关闭文件描述符来设置标准输入/输出重定向,设置环境变量,改变路径等…
  • 然后调用exec(2)执行目标二进制程序

有三种方法可以分支出子进程:

  1. A) fork(2)。可移植且安全(无副作用),但当从高内存占用的虚拟机中调用时,可能会在所有Unix上因ENOMEM失败。在严格的不允许overcommit的Unix上这个问题最明显。

    这是因为分支虚拟机首先会在子进程中创建与父进程理论上相同的内存占用 - 即使你打算接着用一个小的二进制程序调用exec。实际上像写时复制等技术会稍微缓解这个问题,但是我们仍有触发系统限制的风险。

    关于这个问题的一个以Linux为中心的描述,参见Linux proc(5)里对/proc/sys/vm/overcommit_memory的文档。

  2. B) vfork(2): 可移植且快速,但非常不安全。它通过在父进程的内存镜像中启动子进程来绕过与fork(2)相关的内存问题。可能出错的情况包括:

    • 在exec(2)调用之前子进程中的编程错误可能会破坏父进程中调用vfork的线程的堆栈。
    • 在exec(2)调用之前子进程收到的信号可能会错发送到父进程,或者直接杀死子进程和父进程。

    通过在vfork(2)和exec(2)之间严格限制子进程可以做的事情(基本上是什么都不做)来缓解这个问题。
    然而,我们总是在vfork(2)和exec(2)之间做exec之前的准备工作,从而违反了这个规则。

    另外,由于vfork(2)的许多危险性,它已被OpenGroup弃用。

  3. C) clone(2): 这是一个Linux特有的调用,它允许调用者精细地控制进程分支的执行方式。它很强大,但Linux特定。

除了这三种可能性,还有第四种选择:posix_spawn(3)。

fork/vfork/clone都会分支进程并将exec之前的准备工作和调用exec(2)留给用户,而posix_spawn(3)为用户提供了类似Windows上的CreateProcess()的fork+exec类功能,将两者封装在一起。

它本身不是一个系统调用,而通常是在libc内部用(fork|vfork|clone)+exec实现的封装 - 所以与直接调用裸(fork|vfork|clone)函数相比是否具有优势,取决于posix_spawn(3)的实现方式。

注意,在使用posix_spawn(3)时,我们执行exec两次:首先是一个叫做jspawnhelper的小二进制程序,然后在jspawnhelper中我们做exec之前的准备工作,并第二次执行exec,这次是目标二进制程序(类似于 http://mail.openjdk.org/pipermail/core-libs-dev/2018-September/055333.html 中描述的“执行两次技术”)。

这是一个JDK特定的实现细节,碰巧是为jdk.lang.Process.launchMechanism=POSIX_SPAWN实现的。

— Linux特定 —

glibc是如何实现posix_spawn的?
(参见: glibc < 2.24的sysdeps/posix/spawni.c,
glibc >= 2.24的sysdeps/unix/sysv/linux/spawni.c):

  1. 在glibc 2.4之前(2006年发布),posix_spawn(3)只使用fork(2)/exec(2)。 对JDK来说这会很糟,因为我们会面临已知的fork(2)内存问题。但由于这只影响长期被现代发行版淘汰的glibc变种,所以这并不相关。

  2. 在glibc 2.4和2.23之间,posix_spawn使用fork(2)或vfork(2),取决于用户具体如何调用posix_spawn(3):

    当下述任一条件为真时,将使用vfork(2)而不是fork(2)创建子进程:

    • attrp指向的属性对象的spawn-flags元素包含GNU特定标志POSIX_SPAWN_USEVFORK; 或

    • file_actions为NULL,且attrp指向的属性对象的spawn-flags元素不包含
      POSIX_SPAWN_SETSIGMASK、POSIX_SPAWN_SETSIGDEF、
      POSIX_SPAWN_SETSCHEDPARAM、POSIX_SPAWN_SETSCHEDULER、
      POSIX_SPAWN_SETPGROUP或POSIX_SPAWN_RESETIDS。

    由于JDK调用posix_spawn(3)的方式,它会调用vfork(2)。
    所以我们会避免fork(2)的内存问题。但是,vfork(2)仍存在风险。但由于我们使用jspawnhelper,将所有exec之前的准备工作推迟到第一个exec之后,从而大大缩短了易出错的时间窗口,这个风险较小。

  3. 从glibc >= 2.24开始,glibc使用clone+exec:

    1
    2
    new_pid = CLONE (__spawni_child, STACK (stack, stack_size), stack_size,
    CLONE_VM | CLONE_VFORK | SIGCHLD, &args);

    这比(2)要好:

    CLONE_VM意味着我们在父进程的内存镜像中运行,与(2)相同
    CLONE_VFORK意味着父进程等待我们exec,与(2)相同

    但是,错误的可能性由于以下原因进一步减小:

    posix_spawn(3)为子进程传递一个单独的堆栈运行,消除了破坏父进程中分支线程堆栈的危险。
    posix_spawn(3)在第一个exec(2)被调用之前暂时阻塞所有进入子进程的信号。

分支出子进程总结
在glibc上调用posix_spawn(3)
(2) < 2.24不是完美的,但仍然比直接使用vfork(2)好,因为出错的机会大大减少了
(3) >= 2.24是最佳选择 - 可移植,快速且尽可能安全。

muslc是如何实现posix_spawn的?

他们一直使用clone (.. CLONE_VM | CLONE_VFORK …) 技术。所以无论muslc版本如何,使用posix_spawn()都是安全的。

根据上述分析,我们目前默认在所有Unix包括Linux上使用posix_spawn()。

4. 上面文档对应的代码

上面的内容已经说明了问题,如果再继续深入,经过多层会找到 src/java.base/unix/classes/java/lang/ProcessImpl.java 类,这个类中有如下定义:

1
2
private static final byte[] helperpath
= toCString(StaticProperty.javaHome() + "/lib/jspawnhelper");

这个参数在调用下面方法时会传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* Creates a process. Depending on the {@code mode} flag, this is done by
* one of the following mechanisms:
* <pre>
* 1 - fork(2) and exec(2)
* 2 - posix_spawn(3P)
* 3 - vfork(2) and exec(2)
* </pre>
* @param fds an array of three file descriptors.
* Indexes 0, 1, and 2 correspond to standard input,
* standard output and standard error, respectively. On
* input, a value of -1 means to create a pipe to connect
* child and parent processes. On output, a value which
* is not -1 is the parent pipe fd corresponding to the
* pipe which has been created. An element of this array
* is -1 on input if and only if it is <em>not</em> -1 on
* output.
* @return the pid of the subprocess
*/
private native int forkAndExec(int mode, byte[] helperpath,
byte[] prog,
byte[] argBlock, int argc,
byte[] envBlock, int envc,
byte[] dir,
int[] fds,
boolean redirectErrorStream)
throws IOException;

在下面的地方 src/java.base/unix/native/libjava/ProcessImpl_md.c 会调用:

1
resultPid = startChild(env, process, c, phelperpath);

从这儿继续追踪下去就会看到上面文档中提到的方法。

5. 总结

这是一个Linux环境特定的问题,最简单避免的方式就是在Linux环境制作Linux的安装包。

如果遇到类似问题,可以查看jres中所有可执行程序是否有执行的权限。


Linux环境Runtime如何运行子进程?
https://blog.mybatis.io/post/8a968f0c.html
作者
Liuzh
发布于
2023年9月6日
许可协议