2024年12月

jps 无法显示 Java 进程号

对于 Java 应用的诊断, 一直以来都是使用 JDK 自带的 Jps 命令来查看 Java 进程 ID, 最近在一个生产环境的的某个 Kubernetes pod 里面 Jps 一直无法给出对应的进程 ID, 什么输出都没有.

I have no name!@mypod:~$ jps
I have no name!@mypod:~$

初步检查

在 JDK 里面, 很多小工具的命令都搬迁到 jcmd 命令, 如果 jcmd 命令不带任何参数, 其实就相当于 Jps 命令. 于是尝试 jcmd 命令, 结果是一样的.

I have no name!@mypod:~$ jcmd
I have no name!@mypod:~$

以前在 VM 时代, 遇到过类似的问题. 原因是当前运行 bash 的用户和对应的 Java 进程不属于同一个用户, 要切换到对应 Java 进程运行的用户才能看到该进程 ID. 但是这次当前 Bash 的用户和对应 Java 进程的用户本来就是同一个用户.

I have no name!@mypod:~$ ps aux
USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
84289        53  1.7  1.5 48923744 6226704 ?    Sl   Dec02 527:44 /prod/app/jre/bin/java -Xms4096m -Xmx4096m myApp

I have no name!@mypod:~$ id
uid=84289 gid=84677 groups=84677

怀疑当前 pod 的 JDK 文件有啥特别设置(为了减小JDK 大小和不必要文件, 他们把 JDK bin 去掉了, 有需要的移动到了 /jre/bin/ 目录去了). 于是从本地复制一个同版本的 JDK 到目标 pod, 并解压使用, 发现 JpsJcmd 是一样的结果.

但是这时候发现, 如果给 jcmd 命令一个进程ID, 它就能正常运行, 只是无法发现当前 Pod 的其它 Java 进程.

I have no name!@mypod:~$ jcmd 53 help
53:
The following commands are available:
Compiler.CodeHeap_Analytics
GC.class_histogram
GC.finalizer_info
GC.heap_dump
GC.heap_info
GC.run
GC.run_finalization
Thread.print
...

进一步检查

这时候去 Google 了一下, 发现有人说要给 Jps 命令添加一个参数: -J-Djava.io.tmpdir=/tmp/ 告诉它临时文件夹所在的地方. 去试了一下, 发现没有任何改进.
https://stackoverflow.com/questions/3805376/jps-returns-no-output-even-when-java-processes-are-running

于是想使用源码 debug 一下, 于是找到 Jps 的代码: https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.jcmd/share/classes/sun/tools/jps/Jps.java
其实就是2个类, 于是合并一下, 并入到一个类里面, 然后复制到到远程 pod 里面, 命名为: JpsTest.java

import java.util.*;
import java.io.*;
import java.net.*;
import sun.jvmstat.monitor.*;

/**
 * Application to provide a listing of monitorable java processes.
 *
 * @author Brian Doherty
 * @since 1.5
 */
public class JpsTest {

    private static Arguments arguments;

    public static void main(String[] args) {
        try {
            arguments = new Arguments(args);
        } catch (IllegalArgumentException e) {
            System.err.println(e.getMessage());
            Arguments.printUsage(System.err);
            System.exit(1);
        }

        if (arguments.isHelp()) {
            Arguments.printUsage(System.err);
            System.exit(0);
        }
    System.err.println(arguments.hostId());
        try {
            HostIdentifier hostId = arguments.hostId();
            MonitoredHost monitoredHost =
                    MonitoredHost.getMonitoredHost(hostId);
        System.err.println(monitoredHost);
            // get the set active JVMs on the specified host.
            Set<Integer> jvms = monitoredHost.activeVms();
        System.err.println(jvms);
            for (Integer jvm: jvms) {
                StringBuilder output = new StringBuilder();
                Throwable lastError = null;
        System.err.println(jvm);
                int lvmid = jvm;

                output.append(String.valueOf(lvmid));

                if (arguments.isQuiet()) {
                    System.out.println(output);
                    continue;
                }

                MonitoredVm vm = null;
                String vmidString = "//" + lvmid + "?mode=r";

                String errorString = null;

                try {
                    // Note: The VM associated with the current VM id may
                    // no longer be running so these queries may fail. We
                    // already added the VM id to the output stream above.
                    // If one of the queries fails, then we try to add a
                    // reasonable message to indicate that the requested
                    // info is not available.

                    errorString = " -- process information unavailable";
                    VmIdentifier id = new VmIdentifier(vmidString);
                    vm = monitoredHost.getMonitoredVm(id, 0);

                    errorString = " -- main class information unavailable";
                    output.append(' ').append(MonitoredVmUtil.mainClass(vm,
                            arguments.showLongPaths()));

                    if (arguments.showMainArgs()) {
                        errorString = " -- main args information unavailable";
                        String mainArgs = MonitoredVmUtil.mainArgs(vm);
                        if (mainArgs != null && mainArgs.length() > 0) {
                            output.append(' ').append(mainArgs);
                        }
                    }
                    if (arguments.showVmArgs()) {
                        errorString = " -- jvm args information unavailable";
                        String jvmArgs = MonitoredVmUtil.jvmArgs(vm);
                        if (jvmArgs != null && jvmArgs.length() > 0) {
                          output.append(' ')
                            .append(
                                // multi-line args are permitted
                                jvmArgs.replace("\n", "\\n").replace("\r", "\\r")
                            );
                        }
                    }
                    if (arguments.showVmFlags()) {
                        errorString = " -- jvm flags information unavailable";
                        String jvmFlags = MonitoredVmUtil.jvmFlags(vm);
                        if (jvmFlags != null && jvmFlags.length() > 0) {
                            output.append(' ').append(jvmFlags);
                        }
                    }

                    errorString = " -- detach failed";
                    monitoredHost.detach(vm);

                    System.out.println(output);

                    errorString = null;
                } catch (URISyntaxException e) {
                    // unexpected as vmidString is based on a validated hostid
                    lastError = e;
                    assert false;
                } catch (Exception e) {
                    lastError = e;
                } finally {
                    if (errorString != null) {
                        /*
                         * we ignore most exceptions, as there are race
                         * conditions where a JVM in 'jvms' may terminate
                         * before we get a chance to list its information.
                         * Other errors, such as access and I/O exceptions
                         * should stop us from iterating over the complete set.
                         */
                        output.append(errorString);
                        if (arguments.isDebug()) {
                            if ((lastError != null)
                                    && (lastError.getMessage() != null)) {
                                output.append("\n\t");
                                output.append(lastError.getMessage());
                            }
                        }
                        System.out.println(output);
                        if (arguments.printStackTrace()) {
                            lastError.printStackTrace();
                        }
                        continue;
                    }
                }
            }
        } catch (MonitorException e) {
            if (e.getMessage() != null) {
                System.err.println(e.getMessage());
            } else {
                Throwable cause = e.getCause();
                if ((cause != null) && (cause.getMessage() != null)) {
                    System.err.println(cause.getMessage());
                } else {
                    e.printStackTrace();
                }
            }
            System.exit(1);
        }
    }
}

/**
 * Class for processing command line arguments and providing method
 * level access to the command line arguments.
 *
 * @author Brian Doherty
 * @since 1.5
 */
class Arguments {

    private static final boolean debug = Boolean.getBoolean("jps.debug");
    private static final boolean printStackTrace = Boolean.getBoolean(
            "jps.printStackTrace");

    private boolean help;
    private boolean quiet;
    private boolean longPaths;
    private boolean vmArgs;
    private boolean vmFlags;
    private boolean mainArgs;
    private String hostname;
    private HostIdentifier hostId;

    public static void printUsage(PrintStream ps) {
      ps.println("usage: jps [--help]");
      ps.println("       jps [-q] [-mlvV] [<hostid>]");
      ps.println();
      ps.println("Definitions:");
      ps.println("    <hostid>:      <hostname>[:<port>]");
      ps.println("    -? -h --help -help: Print this help message and exit.");
    }

    public Arguments(String[] args) throws IllegalArgumentException {
        int argc = 0;

        if (args.length == 1) {
            if ((args[0].compareTo("-?") == 0)
                || (args[0].compareTo("-h")== 0)
                || (args[0].compareTo("--help")== 0)
                // -help: legacy.
                || (args[0].compareTo("-help")== 0)) {
              help = true;
              return;
            }
        }

        for (argc = 0; (argc < args.length) && (args[argc].startsWith("-"));
                argc++) {
            String arg = args[argc];

            if (arg.compareTo("-q") == 0) {
              quiet = true;
            } else if (arg.startsWith("-")) {
                for (int j = 1; j < arg.length(); j++) {
                    switch (arg.charAt(j)) {
                    case 'm':
                        mainArgs = true;
                        break;
                    case 'l':
                        longPaths = true;
                        break;
                    case 'v':
                        vmArgs = true;
                        break;
                    case 'V':
                        vmFlags = true;
                        break;
                    default:
                        throw new IllegalArgumentException("illegal argument: "
                                                           + args[argc]);
                    }
                }
            } else {
                throw new IllegalArgumentException("illegal argument: "
                                                   + args[argc]);
            }
        }

        switch (args.length - argc) {
        case 0:
            hostname = null;
            break;
        case 1:
            hostname = args[args.length - 1];
            break;
        default:
            throw new IllegalArgumentException("invalid argument count");
        }

        try {
            hostId = new HostIdentifier(hostname);
        } catch (URISyntaxException e) {
            IllegalArgumentException iae =
                    new IllegalArgumentException("Malformed Host Identifier: "
                                                 + hostname);
            iae.initCause(e);
            throw iae;
        }
    }
    public boolean isDebug() {
        return debug;
    }
    public boolean printStackTrace() {
        return printStackTrace;
    }
    public boolean isHelp() {
        return help;
    }
    public boolean isQuiet() {
        return quiet;
    }
    public boolean showLongPaths() {
        return longPaths;
    }
    public boolean showVmArgs() {
        return vmArgs;
    }
    public boolean showVmFlags() {
        return vmFlags;
    }
    public boolean showMainArgs() {
        return mainArgs;
    }
    public String hostname() {
        return hostname;
    }
    public HostIdentifier hostId() {
        return hostId;
    }
}

当前的 JDK 版本是17, 涉及JDK 内部类: sun.jvmstat.monitor, 所以需要特殊的编译运行命令:

# 编译
/tmp/jdk/bin/javac --add-exports jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED JpsTest.java
# 运行
/tmp/jdk/bin/java --add-exports jdk.internal.jvmstat/sun.jvmstat.monitor=ALL-UNNAMED  JpsTest

在上面的类代码里面加入了几处打印信息, 发现对于 //localhost 给出的进程 ID 是一个空列表. 顺着代码去找, 发现这些代码: https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.internal.jvmstat/share/classes/sun/jvmstat/perfdata/monitor/protocol/local/LocalVmManager.java#L129

仔细研究一下, 其实是从 临时文件夹去找特定的文件. 文件夹的命名习惯是: https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/jdk.internal.jvmstat/share/classes/sun/jvmstat/perfdata/monitor/protocol/local/PerfDataFile.java#L61C54-L61C65

于是找到一个本地 Java 应用, 然后去对应的临时文件夹去找对应文件夹, 结果找到如下(本地运行在 container 里面的 Neo4j 的应用):
/tmp/hsperfdata_neo4j/7

hsperfdata_neo4j 是一个文件夹, 里面只有 7 这个文件, 它里面有很多该进程的元数据信息(截取部分如下):

�����7�R �8J0sun.rt._sync_Inflations@8J0sun.rt._sync_Deflations@J8sun.rt._sync_ContendedLockAttempts8J0sun.rt._sync_FutileWakeups0J(sun.rt._sync_Parks~8J0sun.rt._sync_Notificationsw8J0sun.rt._sync_MonExtant8J0sun.rt.createVmBeginTime�bs�8J0sun.rt.createVmEndTimebds�8J0sun.rt.vmInitDoneTime�cs�8J0java.threads.started*0J(java.threads.live'8J0java.threads.livePeak(0J(java.threads.daemon8J0sun.rt.safepointSyncTime�wJ0J(sun.rt.safepoints)8J0sun.rt.safepointTime�c�8J0sun.rt.applicationTimeߪ*DE�0J(sun.rt.jvmVersiopAB+sun.rt.jvmCapabilities11000000000000000000000000000000000000000000000000000000000000008J0java.cls.loadedClasses�(8J0java.cls.unloadedClasses@J8java.cls.sharedLoadedClasses�@J8java.cls.sharedUnloadedClasses0J(sun.cls.loadedBytes��G8J0sun.cls.unloadedBytes8J0sun.cls.sharedLoadedBytes�-8J0sun.cls.sharedUnloadedBytes0J(sun.cls.methodBytes��0J(sun.cls.timet�D[8J0sun.cls.classInitTime��*K8J0sun.cls.classInitTime.
......
sel@sun.classloader.parentDelegationTime8J0sun.classloader.findClasses@J8sun.classloader.findClassTimeHJ@sun.urlClassLoader.readClassBytesTime0J(sun.zip.zipFiles�8J0sun.zip.zipFile.openTime

于是想到对应的 pod 里面在 /tmp/目录新建了 /tmp/hsperfdata_test/53 空文件, 并再次运行 jps. 这次给出了 53 这个进程ID, 只不过没有更多信息.

I have no name!@mypod:~$ jps
53 -- process information unavailable

这时候才发现一开始就忽略了一个信息: 以前运行 Jps 的时候, 至少会出现当前 Jps 本身一行, 一般还有另外一个目标 Java 进程.

回过头来, 我们研究临时文件夹里面的这个文件夹: hsperfdata_neo4j, 它的后半部分其实是运行进程的用户名, 而一开始, 我们就看到我们在生产环境里面的, bash 给我们一直抱怨 I have no name!. 正是因为无法确认当前用户名, 只有用户ID, 才导致在临时目录的 hsperfdata_<user name> 无法创建出来, 最终导致 Jps 失效.

于是去问 chatGPT 关于临时文件夹里面的 hsperfdata_ 文件夹:

在 Java 进程运行的系统上,hsperfdata_ 开头的目录是用于存储 Java 虚拟机(JVM)性能数据的临时目录。这些目录和文件由 JVM 创建,用于支持 Java 性能监控和分析工具,例如 jstat。

这些文件的产生时机和机制:

创建时机:
当 JVM 启动时,它会在系统的临时目录中创建一个以 hsperfdata_ 开头的目录。这个目录的名称通常是 hsperfdata_<username>,其中 <username> 是运行 Java 进程的用户的用户名。
在这个目录中,JVM 会为每个 Java 进程创建一个性能数据文件,其文件名为该进程的 PID(进程标识符)。
创建机制:

JVM 使用这些文件来存储与进程相关的性能数据,例如内存使用情况、垃圾回收统计信息和线程状态等。
这些文件是通过共享内存的方式实现的,允许性能监控工具在不干扰 Java 应用程序运行的情况下访问这些数据。
这些文件在 JVM 进程终止时通常会被自动删除。不过,在某些情况下(例如非正常退出),这些文件可能会残留在系统中。

使用这些文件的工具:

jstat: Java 统计监控工具,用于显示 Java 应用程序的各种运行时统计信息。jstat 工具利用这些性能数据文件来获取 JVM 的性能指标。
其他可能使用这些数据文件的工具包括 jps(Java 进程状态工具)和 jconsole(Java 监控与管理控制台)。
这些性能数据文件对于调试和性能分析非常有用,但在某些情况下,可能需要手动清理残留的 hsperfdata_ 目录,特别是在非正常关闭的情况下。

所以, 正如之前 Google 到的那个问答一样, 最终原因是在临时文件夹下面无法创建出 hsperfdata_ 目录导致的. 这是在早期写 Java 性能监控和分析工具的人无法预料到竟然有的系统上面只能看到用户ID, 不能获得用户名.

bash 里 I have no name!

通常情况下, 我们启动 Docker 都是以 root 为当前用户启动 container 的. 但是我们也可以设置以特定的用户来运行 container 里面的主进程.

  1. 加上 --user 参数

    # 这里指定一个不存在的 user id 1000 来执行. 
    docker run --name noNameTest --user 1000:1000 neo4j:latest
  2. 在 Dockerfile 里面设置用户

    USER <user>[:<group>] # 用户名:组名
    USER UID[:GID] # 用户ID:组ID

但是如果你登录进入该 contianer 却看到你的用户名是: I have no name!, 那说明你的 Shell (Bash 或其它) 再向你抱怨, 它无法找到你当前的用户名. 如下面:

I have no name!@my_container:$ 

I have no name!@my_container:$ id
uid=1000 gid=1000 groups=1000
I have no name!@my_container:$ whoami
whoami: cannot find name for user ID 1000

这是因为 Shell 通常去 /etc/passwd 去找这个用户ID, 却找不到.

在 Kubernetes 里面这样以非 root 用户去运行, 可以最小化权限, 防止特权升级, 提高安全性和隔离性.当容器以不存在的用户 ID 和组 ID 运行时,通常这些用户没有在容器内配置 sudo 或 su 的权限,也没有相应的配置文件(如 /etc/sudoers)来允许权限提升.

在 Dockerfile 中使用 USER 指令指定非 root 用户。
在 Kubernetes Pod 的安全上下文(SecurityContext)中指定 runAsUser 和 runAsGroup 来配置非 root 用户运行。

http status code 431

这周末处理一个 http status code 431 的 case. 431 表示 header 字段的数据太大, 听起来也没那么复杂, 就是消减一下 header 内容就解决了. 但是在大家都说最近没有任何改动的情况下, 到底哪里出问题了呢?

431 表示什么?

https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/431 介绍看. 有 2 种可能的情况:

  1. 所有 header 的字段数据加起来太大了;
  2. 单个 header 的字段数据太大了;

谁会报这个错?

听起来这个问题有点多余, 当然是应用服务器了. 其实不然. 中间可以拆包的所有网络组件都有可能, 比如: 可以拆包的代理, 负载均衡服务器, 如今应用广泛的反向代理服务器 envoy, nginx 等. 他们都可以拆开 http 的报, 检查一下 header 的大小, 然后报个 431 给客户端.

例子

下面便是当时的一个例子, 把其中一个字段加大到60多KB. 可以看到 header 是431, response payload 是一句话.
这个例子是 envoy 拆包后发现 header 很大, 根据 envoy 官方文档: max_request_headers_kb, 它不会验证单个 header 大小, 而是验证整个header 大小. 默认是60K, 所以这里超了.
header.png

网络链路添加 header

通常情况下, 做为客户端发出的所有 header 就是服务器端收到的所有 header, 但是这些中间会拆包的各个组件其实都有可能添加新的 header 到原始的数据报文中. 比如: 一般负载均衡服务器会添加一个 header 告诉下游服务器真正的客户端的 IP 是多少, 这样下游服务器就知道真正的客户IP 是多少了? 否则它通过 remote IP 获得的就是负载均衡服务器的IP.

有时候, 中间拆包的组件可能会添加更多的 header 用来输送更多的信息给下游, 比如: 当前拆包的组件名称, 它的IP, 它的版本之类的.

如何区分到底是最终应用服务器返回431 还是中间拆包组件?

通常情况下, 这些不同的组件或者应用服务器返回的 response header 里面会标明它的身份. 比如我们的例子中它就是中间的 envoy 组件返回的, 不是最终的应用服务器. 应用服务器会返回带不同 header response.

为什么 header 会很大?

各种原因都会有, 比如:

  1. Referer 是一个巨长的 URL.
  2. Cookie 的内容太多 (Cookie 的内容都是header里面的)
  3. 某个 header 添加了巨多内容.

具体在我们这个case 里面, 就是客户端有个小系统, 它为了不让下游子系统去到一个远程系统取很多数据, 直接把这部份内容放到 header 发给下游了, 然而这部份内容是一个动态变动的部分, 就像一个list, 会随着系统增加减小, 正巧它这次很大, 就遇到这种情况.