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 进程的 符号表文件. 下面就是关于如何产生符号表文件的教程.

下面是详细教程:

  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/ebayjdk/jdk11/jdk
  3. 编译

    cmake .
    make
  4. 产生 perf-<pid>.map

    ./bin/create-java-perf-map.sh $(pgrep java)
    # 到 /tmp 目录查看对应的 perf-<pid>.map 是不是存在了

更多实用工具在 bin 目录

可能遇到问题:

  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-<pid>.map 要使用 java 应用启动相同的用户名, 因为 java 应用的 agent 要使用同样的用户才能访问对应的 java 程序.

Linux 的 scheduler

Linux 从 2.6.23 开始使用 CFS(Completely Fair Scheduler). 在 Linux 内核看来, 它不区分进程和线程, 每个都是可调度的 Thread.

每个新创建的 Thread 都有一个 Schedule Policy 属性和一个 Static Priority 属性. CFS 主要根据这2 个属性的值, 确定当前线程是不是能分配 CPU 时间.

Linux 上的 Scheduler Policy 主要有:

  1. SCHED_OTHER # Linux kernel 里面又名 SCHED_NORMAL
  2. SCHED_IDLE
  3. SCHED_BATCH
  4. SCHED_FIFO #First In First Out 先进先出
  5. SCHED_RR # Round Robin 是 SCHED_FIFO 的改进版本

以上这些 Policy 又被分为 2 大类(Classes):

  1. Normal scheduling policies (正常) 包含 SCHED_OTHER, SCHED_IDLE, SCHED_BATCH;
  2. real-time policies (实时) 包含 SCHED_FIFO, SCHED_RR

对于 Normal scheduling policies 的线程, 它的静态优先级一定是 0, 这个值在scheduler 安排他们在 CPU 上执行决策的时候, 是不考虑的.
对于 Real-time policies 的线程, 它的静态优先级范围是 1(低) ~ 99(高).

从概念上讲(实际并不一定是这么做的, 效果是一样的), 每个优先级都有一个线程列表, 里面都是同优先级的线程. CFS 从高优先级队列到低优先级队列按照顺序去找, 高优先级队列里面执行完了, 再执行第优先级队列. 同一个队列里面从头往后执行.

不同优先级线程之间根据优先级(priority) 去安排, 相同优先级的线程根据 Scheduler Policy 去安排执行顺序和时间;

Linux 默认的线程 Policy 是SCHED_OTHER, 所以这些默认的静态优先级都是 0. 它们都比 Real-time 的线程优先级低. 对于 SCHED_OTHER 队列里面的线程, 因为静态优先级都是0, 所以它们使用动态优先级(dynamic priority) 来决定谁先运行. 动态优先级使用 nice 的值来决定谁先运行. nice 的值决定一个线程在一个时间区间内可以运行的时长, 通常情况下没有 nice 值的线程都有相同的运行时长, 改变 nice 的值, 就能改变在一个时间区间内它能运行的时长.

nice 的值只对 policy 是 SCHED_OTHER & SCHED_BATCH 的线程有效. 在 Linux 上 nice 的值是 per-thread 的. 在当前的 Linux 系统, nice 的值可以是: -20 (high priority) to +19 (low priority). 它意味着你越 nice, 你的优先级越低, 越不 nice (负值), 你的优先级就越高. 当前 Linux 系统里面, nice 的值相差一个单位, 一个时间区间内大概运行时间比原来是 1:1.25.

PolicyStatic PriorityDynamic Priority(nice)
SCHED_OTHER0-20 ~ 19
SCHED_RR0 ~ 99N/A

我们看到上面说 Linux 的 Real-time 的线程优先级是 1(低) ~ 99(高), 而 Normal 的线程优先级是 0, 根据 nice 可以调整的值, Normal 线程可以做到nice 的值 -20 ~ 19, 如果 mapping 的到优先级, 则可以是 0 ~ 39. 这里 0 ~ 39 值越低, 优先级越高. 如果把 Real-time 的优先级都加一个负号, 就变成了 -99(高) ~ 1(低), 那么就可以连起来了. 所有优先级是: -99 ~ 1, 0 ~ 39, 并且值越小, 优先级越高. 所以可以认为 Normal 的优先级默认是 20(本来是 0), 通过 nice 改成了 0(-20) ~ 39 (19). 这样一来, 是不是就跟 top 里面的 prio 和 ni 值匹配了?

普通用户只能使用 nice 的值把进程调的更 nice (0 ~ 20), 不能调的更不 nice (-10 ~ -1). 但是 root 可以.

另外, 当没有 CPU 竞争的时候, 比如系统有 8 个 CPU, 只有 3 个在忙, Priority 基本不起作用.

参考: https://man7.org/linux/man-pages/man7/sched.7.html