JVM 的 debug 符号

下面内容是从 https://www.opsian.com/faq/ 页面抄的:

If you've downloaded a JVM from a normal distribution - for example Oracle, Azul or Adopt OpenJDK - then it will come with debug symbols built in. Many distributions do not ship debug symbols with their JDK packages, this will result in some features of the Opsian agent not being available. It is easy to install the debug packages.

Ubuntu - If you are using Java 11 then run apt install openjdk-11-dbg or if you're running Java 8 then run apt install openjdk-8-dbg
Red Hat / Centos - If you are running version 7 or later then you need to Enable the DebugInfo repository on Red Hat or Centos. Then you need to install the appropriate debug info package for your JDK version, for example yum -y install java-1.8.0-openjdk-debuginfo.x86_64 if you're using Java 8.

附带一张 18 年参加 SRE con 上面别人的总结:
srecon18americas_slides_goldshtein_pdf__page_8_of_74_.png

L4 & L7 LB

  1. L4 使用 OSI 模型的第 4 层的信息来决定下一个路由是哪里, 一般使用源 IP, 源端口, 目标 IP, 目标端口;
  2. L4 一般使用专门的芯片快速解包, 修改包. 如今 CPU 和内存都足够快, 越来越多使用软件 LB;
  3. 所谓修改包就是做 Network Address Translation (NAT), response 回来时候, 也要修改回去;
  4. 因为只使用 L4 包头的一些信息, 所以只要提取包头的少量信息就可以了;
  5. L7 是分析应用层协议的属性来判断下一个路由, 同样它也要拆包, 并且要拆到第 7 层, 然后修改包;

l4l7.png

如何找到 JVM 里面的 native C 代码

比如 java.lang.Thread.java 里面定义了定义了一些 native 代码, 那么如何找到这些 native 代码呢? 使用 google search 对应的 .c 文件:
thread.c site:openjdk.java.net

又比如: sun.nio.ch.FileDispatcherImpl.java 里面定义了一些 native 代码, 那么只要 search:
FileDispatcherImpl.c site:openjdk.java.net
就可以了.

记录一次线上 connection reset 排查

有开发人员发现他们的应用程序经常报一个空指针的错误, 他们自己查不出来是啥原因, 就报给了网络团队, 因为在这个空指针前面, 打了一行日志, 说接收回应数据为空(Response is empty), 他们认为网络时不时有问题.

  1. 找到对应的代码, 发现有人写了一段这样的代码:

    try {
    //access another service
    Response rsps = callAnotherService();
    } catch (Exception e) {
    logErr("Response is empty", e.getMessage);
    }
    return rsps;
  2. 上面这段代码中, 捕获异常, 只是打印了日志, 没有做错误处理, 这时候返回的 rsps 肯定为 null, 就可能导致上层调用者空指针异常. 通常我们需要封装之前的 Exception, 如果没有封装, 那么最好打印之前异常的错误栈到日志中, 这样方便排查问题出在哪一行. 另外这里有个特殊的地方, logErr() 方法的第二个参数并没有打印到日志中, 而是做了其它用途, 导致开发人员最后只看到 "Response is empty", 没看到具体出错原因.
  3. 对这行代码改造之后, 我们看到了出错栈和具体原因: connection reset by peer. 就是连接被断掉了.
  4. 于是做 tcpdump, 发现对于这种情况基本是客户端发出请求后, 服务端就 reset 了连接;
  5. 由于中间使用了 LB, 复制 LB 的同事在 LB 上双向抓包, 说是服务端主动 reset 连接;
  6. 于是在服务端抓包, 确实发现有时候服务端在收到请求后主动 reset 连接;
  7. 观察服务端的情况, 发现有些发来的请求服务端已经在处理了, 等处理完发现连接已经被 reset, 有些根本没进入服务端进行处理. 其它观察到的一些情况

    1. 服务端是异步 IO, 所以有些线程负责建立连接, 负责数据的读取写入, 有些负责业务处理;
    2. 服务端并没有发现特殊的线程 interrupt 的情况(这种情况很容易造成 IO 连接中断);
    3. 服务端在处理的时候, 又使用了 @suspended AsyncResponse. 基本是 Tomcat server 线程处理了 token 验证, 之后就留给线程池处理了.
  8. 通过 perf 过滤操作系统层面的 tcp:reset, 确实发现一些从tomcat 端口 reset 的连接, 可是由于是没有java 代码的栈, 所以看不出为什么要 reset;
  9. 于是对 java 进程添加 -XX:+PreserveFramePointer, 生成符号表文件, 成功看到部分 java 栈. 可是由于另外一些还是热度不够, 看到的代码仍然是翻译过来的, 所以看到的都是 Interpreter:
    interpreter.png
  10. 不过通过async-profiler 持续抓包, 终于捕获类似一个 java 栈:
    close_reset.png
  11. 通过 2 个栈相结合, 能看到导致 reset 的原因是: 代码中出了某种问题, 要 close 连接, close 的时候, 发现正常 close 的条件没达到, 比如还有没读的数据, 只能直接 reset;
  12. 导致 close 的代码段是:
    close.png

到底是什么情况导致 cancelledKey() close, 未完待续

perf-map-agent 使用步骤

使用 asyncProfiler 能捕获 Java 的栈, 使用 perf 能捕获操作系统栈, 由于 JVM 使用自己单独的虚拟机, 所以不能同时访问 2 部分栈. Netflix 的教程(https://netflixtechblog.com/java-in-flames-e763b3d32166), 能捕获 2 个在一起的栈. 需要做 2 件事情:

  1. 对 Java 进程添加 -XX:+PreserveFramePointer flag
  2. 生成当前正在运行的 Java 进程的 符号表文件. asyncProfiler 和 bpftrace 都需要这个文件在 /tmp/ 目录下, 并且以 perf-<pid>.map 的形式存在.

如 ls -lah /tmp/perf-2868448.map

假如不使用 Java 运行时符号表

你将看到如下的栈信息, 毫无用处:

  0x7f72beee3337
  0x7f72be72e399
  0x7f72be72ed3e
  0x7f72be1a7d98
  0x7f72be1aacb1

下面就是关于如何产生符号表文件的教程.

  1. clone 或者下载最新版本 https://github.com/jvm-profiling-tools/perf-map-agent

    curl -vvv 'https://github.com/jvm-profiling-tools/perf-map-agent/archive/refs/heads/master.zip' --output perf-map-agent.zip
    
    # or
    git clone https://github.com/jvm-profiling-tools/perf-map-agent.git
    
  2. 设置 JAVA_HOME 环境变量

    export JAVA_HOME=/home/supra/work/tools/myjdk/jdk11/jdk
  3. 编译

    # 解压并进入该文件夹
    unzip perf-map-agent.git && cd perf-map-agent
    cmake .
    make
  4. 产生 perf-.map

    ./bin/create-java-perf-map.sh $(pgrep java)
    # 到 /tmp 目录查看对应的 perf-<pid>.map 是不是存在了
    ls /tmp/ | grep .map
    -rw-rw-r--  1 root  root   29K Nov 13 06:29 perf-2868448.map

使用 bpftrace 的例子

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_pread /comm=="java"/{ @[ustack(20)] = count(); }'

# or
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_pread /pid==2868448/{ @[ustack(20)] = count(); }'

可能遇到问题:

  1. 如果遇到下面:

    -- The C compiler identification is GNU 7.5.0
    -- The CXX compiler identification is unknown
    -- Check for working C compiler: /usr/bin/cc
    -- Check for working C compiler: /usr/bin/cc -- broken
    CMake Error at /usr/share/cmake-3.10/Modules/CMakeTestCCompiler.cmake:52 (message):
     The C compiler
    
     "/usr/bin/cc"
    
     is not able to compile a simple test program.
    
     It fails with the following output:

    则安装编译工具

    sudo apt-get cmake
    sudo apt-get install build-essential
  2. 如果遇到下面:

    Sorry, user xxx is not allowed to execute '/yyyy/bin/java -cp /home/xxx/perf-map-agent-master/bin/../out/attach-main.jar:/yyyy/lib/tools.jar net.virtualvoid.perf.AttachOnce 23545 ' as xxx on hostzzzz.txh.com.

    那么我们可以到 perf-map-agent-master/out/ 目录下去执行这个命令就好了, 不是执行 create-java-perf-map.sh.

  3. 关于要如何使用 root 的, 查看 Netflix 的那个文档
  4. 创建 perf-.map 要使用 java 应用启动相同的用户名, 因为 java 应用的 agent 要使用同样的用户才能访问对应的 java 程序.
  5. 如果你发现即便使用了运行时 Java 进程的符号表, 可是还是很多翻译的栈, 如下:

    __GI___recv+110
     Java_java_net_SocketInputStream_socketRead0+434
     Interpreter+28336
     Interpreter+4352
     Interpreter+4352
     Interpreter+4352
     Interpreter+4352
     Interpreter+5875
     Interpreter+4352
     Interpreter+4352
     Interpreter+3728
     Interpreter+3728
     Interpreter+5184
     Interpreter+5184
     Interpreter+4352
     call_stub+138
     JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, Thread*)+883

    因为只有编译的代码才有符号表, 翻译的是没有的, 所以要让它尽快尽早JIT编译, 因此, 要通过下面2个参数:
    -XX:-TieredCompilation -XX:CompileThreshold=1
    2个参数分别是: 禁止分层编译, 然后只要运行1次就JIT编译. 所以完整的命令是:
    java -XX:+PreserveFramePointer -XX:-TieredCompilation -XX:CompileThreshold=1 myJavaApp.
    同时要确保你的编译cache 足够大.
    这样, 你就得到了如下的代码栈:

     __GI___recv+110
     Java_java_net_SocketInputStream_socketRead0+434
     Ljava/net/SocketInputStream;::socketRead0+244
     Ljava/net/SocketInputStream;::read+224
     Ljava/io/BufferedInputStream;::fill+784
     Ljava/io/BufferedInputStream;::read1+176
     Ljava/io/BufferedInputStream;::read+252
     Lsun/net/www/http/HttpClient;::parseHTTPHeader+444
     Lsun/net/www/http/HttpClient;::parseHTTP+1004
     Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0+1420
     Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream+196
     Ljava/net/HttpURLConnection;::getResponseCode+96