针对进程设置路由规则

最近有一个需求是这样的:Linux 机器上有多个网络的 interface,想要让其中的一个程序使用 eth1 而不是默认的 interface,不影响其他程序。

在 Linux 上发送 TCP 数据,是通过 kernel 提供的 syscall 将数据送给 kernel,然后 kernel 负责最后的发送。而到了 kernel 这一层,TCP 就是根据五元组来确认的了:来源端口,目的端口,来源IP,目的IP,以及协议类型。所以就让“按照进程转发”这个需求变得有些复杂。

Linux 提供了 SO_MARK 可以给流量“打标签”,然后针对打了标签的流量设置 route. 我们的思路是这样的:

  1. 设置一个路由规则,将带了 0x10 mark 的流量通过 table 100 查找路由: ip rule add fwmark 0x10 table 100
  2. table 100 中只有一条 default 路由规则,就是通过 eth1 出去: ip route add default dev eth1 table 100 scope global
  3. 然后给特定程序使用 SO_MARK 打标签。这样,就可以命中我们想要的路由了。

如何给这个流量打标签,变得有趣了起来。

方法1:iptables

如果应用使用的流量有明显的特征,就可以用这种方法。比如我们的流量都是去往 3306 的,就可以针对这个端口设置一条规则:

但是话说回来,既然都知道端口了,可以直接把步骤 3 去掉,在步骤 1 让去往这个端口的流量直接命中 table 100(ip-route(8) 是支持端口的):

所以这个方法感觉比较鸡肋。而且这种只支持程序的流量必须有一个固定的模式,假如程序请求很多其他网站并且 TCP 无法识别,就麻烦了。

方法2:LD_PRELOAD 魔法

使用这种方式,我们可以不修改程序的代码而又让进程的流量全部带上标记。

第一次见到 LD_PRELOAD 是在 jvns 的博客上,简直是一个颠覆世界观的东西,它这么简单,又这么有效!

它的原理是这样:dynamic link 的程序是在启动的时候寻找应该从哪里 load 一个 symbol 的,通过设置 LD_PRELOAD 给进程,就可以覆盖动态链接的符号的查找顺序。

ldd 展示动态链接的库:

所以,当我们编译一个 .so 共享库,并且通过 LD_PRELOAD 设置成共享库的路径,那么在寻找符号的时候就会先寻找 .so 里面的,这样,我们就可以通过编写同名的函数来覆盖系统的函数。

如果你用过 proxychains ,那么你应该已经用过这个 feature 了。

proxychains 是一个代理软件,一般的软件使用代理都需要设置 http_proxy 等环境变量来告诉软件代理的地址。前提是这个软件支持读这些环境变量。但是,有了 Proxychains,你不需要软件自身支持,就可以让它走代理!原理就是,软件通过 proxychains 启动,而启动的时候,proxychains 会设置软件的 LD_PRELOAD 变量,然后去替换系统的 socket 函数,这样,任何调用都会走 proxychains 的代理!source code

所以,使用这种方法,我们只要替换程序的 socket 方法,在每次创建 socket 之后,调用 setsockopt(2) 去设置上 SO_MARK 就好了。

网上已经有一个程序 App-Route-Jail 做了这件事,它的使用方法如下:

1.clone 并且编译程序:

2.启动程序的时候带上 LD_PRELOAD 变量,要设置什么 mark,通过 MARK 环境变量传入

它的程序很简单,就是用一个新的 socket 函数,替换原来的 socket 函数:在每次创建 socket 之后,调用 setsockopt(2) 设置 mark.

等下,那既然 mark.so 里面调用的也是 socket(),怎么避免 linker 再去找一遍这个函数在哪里,然后找到了自己,造成无限循环呢?

mark.c 的代码中,是用 dysym(3) 去 load 的 socket() 这个符号:

RTLD_NEXT 就是让 dlsym 从下一个 .so 开始找:

RTLD_NEXT
Find the next occurrence of the desired symbol in the search order after the current object. This allows one to provide a wrapper around a function in another
shared object, so that, for example, the definition of a function in a preloaded shared object (see LD_PRELOAD in ld.so(8)) can find and invoke the “real” func‐
tion provided in another shared object (or for that matter, the “next” definition of the function in cases where there are multiple layers of preloading).

所以这里用的就是 glibc 提供的 socket 函数了。

这个方法也有缺点,就是它只对 dynamic link 的程序有效,像 golang 这种语言默认是全部静态编译的,连 glibc 都不用,LD_PRELOAD 自然就无效了。这种情况我们就只能修改程序的代码,让它自己标记自己的流量了。

方法3:直接在 golang 代码中直接设置  fwmark

我们需要手动创建 Dialer 来进行 tcp 连接。并且给这个 Dialer 设置一个 Control:

 

感觉使用这种方式在 Chaos Engineering 方面也会很有用,我们一直苦于如何有效对 HTTP(S) 流量进行注入而不影响其他机器的进程。使用 SO_MARK 或许可以解决。


2023年3月1日更新:这篇文章发出之后,网友评论了更多有用和有趣的方法。

方法4:通过 network namespace 配置路由

最先是在 twitter 上看 @Sleepy93216599 介绍的。不过没尝试过,就不详细展开介绍了。

方法5:通过 iptables –uid-owner 匹配

iptables 居然支持直接按照 uid 来匹配,之前一直不知道这个。这样的话只要给进程分配一个单独的 user 来运行就好了。

此外,还支持 gid, pid, sid, cmd owner 来匹配:

–uid-owner userid
Matches if the packet was created by a process with the given effective user id.
–gid-owner groupid
Matches if the packet was created by a process with the given effective group id.
–pid-owner processid
Matches if the packet was created by a process with the given process id.
–sid-owner sessionid
Matches if the packet was created by a process in the given session group.
–cmd-owner name
Matches if the packet was created by a process with the given command name. (this option is present only if iptables was compiled under a kernel supporting this feature)

方法6:使用 ptrace(2) hook 系统调用 connect(2)

这个方法简直惊为天人。

本文评论里介绍了 gg 这个工具,可以用来 hook golang 写的程序,感觉很神奇,但是没看懂怎么实现的,只有 FAQ 里面提过一次 ptrace,然后灵光一闪发现可以用 ptrace 搞事情。

后来看到 graftcp 这个工具,文档和代码写的都很好,看的让人拍大腿。简单来说,就是程序启动的时候,通过 ptrace(2) 进行跟踪,然后当程序开始创建 tcp 的时候,拦截 connect(2) 调用,获取目标地址,然后给这个程序修改成自己的地址,欺骗程序,自己拿到了真正的地址。然后恢复执行。后面程序成功创建了 tcp 连接,但是却是到代理的 tcp 连接,而不是真正到目标地址的链接。这样,代理就在这之间工作了。而这对程序是无感的。

文档还提到一个有趣的点,就是为什么不直接去 hook write(2)read(2)呢? 原因有2:

  • 不方便拿到真正的目标地址(原来的方法 hook connect(2)) 是通过 PIPE 把地址告诉代理进程。
  • 去改写程序的 buffer 内存,会造成缓冲区溢出等错误。(而原来的方法直接修改 connect(2) 的参数是在寄存器,安全的多)。
  • 另外 ptrace 是有性能损耗的,直接hook一次,在连接创建阶段,显然要比每次传输数据都暂停一下,性能要高得多。
 

Tcpdump 从 TCP_option_address 中根据真实 IP 过滤

这几天做了一个集群的迁移,我们搭建了一个新的集群,然后更新了 DNS 让域名访问新的集群,准备给老集群下线。下线的时候发现仍然有一部分请求到了老集群。看来是它们用了长链接,并没有根据新的 DNS 记录将请求发给新的地址。找到这些发送请求的客户端是谁,费了一番功夫,这里记录一下。

我们的架构如下:

 

LVS 是一个四层代理,将请求转发到 Nginx,一个七层代理,然后转发给后端应用。

现在的情况是:

  1. Nginx 转发给后端的 real server,real server 是同一组,即,从后端 real server 这一层,不知道是谁还通过旧的集群进行访问;用户来源的名字写在了一个特殊的 header 中;
  2. 我们可以从 Nginx 的 access.log 找到这些请求的真实 IP,但是这是不够的,因为用户请求访问走了 NAT 进行了地址转换,我们仍然不知道这个 IP 是哪一个团队的;

但是我们有了 IP,有了 Header,只要通过 tcpdump -A host <ip> 就可以将用户请求的 HTTP 内容全部打印出来,也就包括 header,就能知道是谁了。

这里有一个问题,就是 Nginx 在 LVS 后面,我们在 Nginx 上面进行 TCP dump,看到的是 LVS 的地址,而不是用户的真实地址。

LVS 里面使用了一个模块,叫做 TOA, TCP_option_address. 即 LVS 进行转发的时候,会将用户的真实 IP 写在 TCP 的 option 字段中。如果要根据真实地址进行 tcpdump,我们要过滤的是这个字段而不是原生的 host 地址。

注意这个 TOA 只会在 SYN 握手阶段设置,然后双方会把这个信息记录在内存里面,后面的 TCP 通讯就不会一直带上这个信息了。所以,要找到用户的真实 IP,必须过滤 TCP 的 SYN 包。

现在需求就变成了:用 tcpdump 在 Nginx 上,filter 出来 IP 为 A 的请求的 HTTP header,以便根据 header 中的信息找到调用来源的团队,和他们沟通重建连接的问题。

通过阅读 TOA 的源代码可以发现,代码中还原用户真实的 IP 地址的方式:遍历 TCP Option 字段,直到找到 option code 为 254(TCP option 254 是一个实验字段,RFC3692-style Experiment 2 (also improperly used for shipping products), opsize 固定为 8 的 option,就读 16 bit load成端口,读 32 bit load 成 IP.

所以只要针对这个 tcp option filter 即可。

需要注意的是,tcpoption 对于 tcpdump 来说不被理解,所以需要转成 hex 来匹配。我在 xbin 上面放了几个工具:

匹配 IP 是 120.120.111.111 的话可以用下面的语句:

其中:

  • tcp[tcpflags] & tcp-syn != 0是取 SYN 的 flag 为 1,即只抓取 SYN 包;
  • tcp[24:4]=0x78786F6F 是匹配 tcp option 字段中的 IP

如果要匹配 port 的话,就偏移两位即可:

 

这样做其实还有一些问题:

  • 上面说过 tcp option 里面有 IP 只发生在握手阶段,所以这样是抓不到后续的数据的。没找到 tcpdump follow tcp stream 的方法,可能还是全 dump 下来,然后去 wireshark 里面过滤比较好。不过我们这里 SYNACK 的时候已经有 HTTP 请求内容了,所以只过滤握手第三个包也足够;
  • 还有一个问题是,看 TOA 的代码,它是遍历所有的 TCP option,根据 option Kind + option size 去查是不是自己的 port + IP 字段。也就是说,port + IP 未必一定是在 TCP option 的开头,偏移未必一定是 tcp[24:4]。一个解决方法是把可能的都 dump 下来,然后用 wireshark 过滤,wireshark 支持搜索全部 TCP 包内容包含某个 hex value。比如:tcp.options contains fe:08:78:78:6f:6f.
  • 因为我们要找到的是长连接的用户,所以这些连接我们是没有 SYN 包的。所以本文描述的方法其实对本文提出的问题……无效。现实中我们通过其他的方法找到用户了。这篇文章权当是看个热闹吧。
 

用 BPF 动态追踪 Python 程序

最近在学习 BPF,这是一种目前比较流行的动态追踪技术,简单来说,它允许我们在不中断目前正在运行的程序的情况下,插入一段代码随着程序一起执行。比如你想知道某一个函数每次 return 的值是什么,就可以写一段 BPF 程序,把每次 return 的值给打印出来;然后把这个函数 hook 到函数调用的地方,这样,程序在每次调用到这个函数的时候都会执行到我们的 BPF 程序。

这种不终止程序,能去观测程序的运行时的技术是很有用的。

credit: https://www.brendangregg.com/ebpf.html

它的原理大致上是:

  1. 找到要观测函数调用的地址(所以用 BPF 的时候我们通常需要带 debug 符号的 binary),将这个地址的指令换成 int3,即一个 break,把原来应该执行的指令保存起来;
  2. 当程序执行到这里的时候,不是直接去调用函数了,而是发现这里是一个 uprobe handler,然后去执行我们定义的 handler;
  3. 原来的程序继续执行;
  4. 当我们停止 probe 的时候,把原来保存的指令复原。

例子:这行程序可以把系统中所有执行的命令打印出来 bpftrace -e 'tracepoint:syscalls:sys_enter_execve { join(args->argv); }'. 原理就是,每次执行 execve 的时候都会执行到我们定义的 join (print).

bpftrace exmaple

bpftrace 是一个命令行程序,它的语法类似 awk,做的事情就是:将我们写的程序(这里的程序指的是 bpftrace 定义的语法,而不是 BPF 程序)编译成 BPF 程序,然后 attach 到相应的事件上。

通过上面的描述可以看出,使用这个方法来追踪程序所需要的必备条件是:

  1. Kernel 要支持 BPF;(4.19+);
  2. 有 binary 可以追踪;

像 Python 这种解释型语言,binary 其实是一个解释器,解释器里面才是运行的 Python 代码。如果按照上述方法,追踪的其实是解释器的代码而不是我们的 Python 应用的代码。

Python Interpreter

使用 USDT 追踪 Python 程序

一个解决方法是使用 USDT 追踪。

USDT 全称是 User-level statically defined tracing. 是在程序的用户态定义的追踪点。Python 解释器为我们提前定义好了一些追踪点(可以叫做 probe,或者 marker),这些追踪点默认是不激活的,所以对性能丝毫没有影响。要追踪的时候可以激活其中的几个追踪点,然后将其提供的 arguments 打印出来。

能够使用 USDT 有几个前提:

  1. Kernel 要支持 BPF;(4.19+); (其实使用 Dtrace 或者 systemtap 也可以)
  2. Python 编译的时候要打开 trace 的支持 (编译的时候有 –with-dtrace 选项),我的几个服务器中,Ubuntu 上面的 Python3.10 是开了的,Fedora 上面的 Python 3.10 貌似没有开。

使用 USDT 追踪 Python 程序

首先需要安装 bpftrace. 安装说明可以参考这里

通过这个命令可以列出 binary 中的 USDT markers: bpftrace -l 'usdt:/usr/bin/python3:*'

可以看到目前支持的 markers 不多,一共也就 8 个。

先拿一个来试一下:执行这个命令可以打印出来 Python 每次函数调用的时间、源代码的地址,函数名字,和行号:

bpftrace -e 'usdt:/usr/bin/python3.10:function__entry {time("%H:%M:%S"); printf(" line filename=%s, funcname=%s, lineno=%d\n", str(arg0), str(arg1), arg2);}' -p 15552

其中,-p 15552 是正在运行的 Python 程序的 pid,-e 后面跟的就是 bpftrace 的代码,很像 awk,第一个是 probe,{} 里面是代码,主要要两行,第一行打印出来时间,第二行打印出来 function__entry 这个 probe 提供的三个参数。(需要稍微注意的是,字符串参数需要用 bpftrace 内置的 str() 函数打印出来字符串,否则的话打印出来的是 char * 的地址;另外要注意虽然 Python 文档中说的这三个参数是 $arg1 $arg2 $arg3,但是实际打印的时候,应该使用的是 arg0, arg1, arg2.)

效果如下:

其他的几个 marker 作用是:

  • function__entry: 函数入口
  • function__return: 函数退出的时候
  • line: 每执行一行 Python 代码都会触发
  • gc__start和gc__done: gc 开始和结束的时候触发
  • import__find__load__start 和 import__find__load__done:import 开始和结束的时候触发
  • audit:sys.audit 调用的时候触发

可以看到,当前支持的并不多,而且从这几个 marker 可以看出,大部分的作用是让你知道 Python 解释器正在干啥,而不是 debug。如果遇到性能问题的话可以通过这个解决,但是如果遇到逻辑错误,帮不上太大的忙。比如你无法通过 usdt 看到某一个变量在某个时刻的值。

USDT 的原理

USDT 工作的原理和上文提到的 uprobe 差不多,激活的时候会改变原来执行的指令,插入 int3 去执行我们的 handler.

Seeing is believing.

我们可以通过一些工具来观察它执行的原理。

function__entry 为例,我们先找到它在 Binary 中的 marker, 在 .note.stapsdt 里面:

可以看到我们刚刚使用的 function__entry 的位置是 0x000000000005d839.

然后我们找到这个位置的指令。

gdb -p 17577 去 attach 到正在运行的这个 pid 上。然后运行 info proc mappings dump 出来地址,可以看到 Offset 0x0 的位置是 0x55bfcc88d000.

那么 function__entry 的位置应该是 0x000000000005d839 + 0x55bfcc88d000.

下面用 disas 命令可以 dump 出来这段地址的指令:

查找 0x55bfcc8ea839 这个地址的指令:

发现是一个 nop,即什么都不做。所以这就是为什么上文说,在 usdt 不开启的时候,对性能是完全没有损失的。只是多了个一个什么都不做的占位指令而已。

下面使用之前的命令去 attach 这个 usdt probe:

然后重新看一下 0x55bfcc8ea839 这个位置的指令:

可以看到这个位置的指令已经变成了 int3. 当程序(解释器)执行到这里的时候,kernel 就会执行用 usdt 提供的变量去执行我们的 BPF 程序。当 BPF probe 退出的时候,int3 会被恢复成 nop.

更多的可能?

在网上查询和 Python 有关的 BPF 内容大部分都是“如何通过 Python(BCC)来使用 BPF”,而不是“如何用 BPF 去 profile Python 代码”,可能在解释型语言方面用的不多吧。

USDT 有很大的局限,就是只能使用 Python 解释器定义的几种 probes, 目前能做到的基本上是看看解释器正在干嘛,而不能看到一些具体的变量的参数,解释器的状态等等。如果要做到更加精细的动态追踪,目前有两个想法:

  1. 用 uprobe 去追踪 Python 解释器。不过大部分都是 PyObject 指针,需要了解解释器的工作原理,比较复杂;
  2. 自定义更多的 USDT。通过 python-stapsdt 这个库可以在 Python 程序中插入更多的 USDT marker。但是我觉得意义也不大:我们要解决的问题,往往是不知道问题出在哪里了,你要解决问题的时候,又不能停止程序,再想起来要插入 marker,就晚了。换句话将,既然知道一个地方可能会出问题,可观测行非常重要,那么为什么不直接打印日志呢?
 

2022 年的总结

2022 年终于过去了,对于大多数人来说是糟心的一年,但我觉得也有一些指的记录的地方。

今年完成的最大的事情是10月份举办了婚礼。前前后后几乎策划了一年多,所幸举办的还算圆满。办完之后又办了很多手续,飞回新加坡回到工作中,本来是年底旅游的好日子,却几乎没出去旅游,躺平休息了 2 个多月。(办完婚礼之后觉得工作简直太轻松了)今后有了另一个身份,做一个好丈夫!

今年做的一些 side projects: 首先是 xbin.io, 时间太长,我都以为是去年做的东西了。这个项目的初衷是满足我自己的一些需求,现在看目的也达到了,个人对现在的形态比较满意,使用率也还可以。在新加坡使用 xbin 的 latency 是 5ms (ping RTT),如果不是在国外公司,自己是不会有这个想法做这么个东西的。

iredis 在今年几乎没有什么大的改变,只是在处理一些用户的 issue 和 bug. iredis 一直有一个问题,就是所有命令的补全都是我自己编入的,每一次 redis-server 有什么 command 的变更,我都要跟进,否则的话自动补全和校验系统就识别不出来新的命令,或者同一个命令的新的语法。我一直想把它替换成根据 redis-doc 中的文档自动补全的。现在这个文档已经越来越规范了,也许现在是时候迁移到自动生成的补全系统了。下一年花一些时间钻研一下语法树和补全系统,争取能完成这个。

监控系统。做 SRE 的这段时间或多或少都在和监控系统打交道,今年为了解决我们团队遇到的问题,我从头搭建了一套监控系统,基于 VictoriaMetrics 上的,花了很多时间阅读他们的文档,也提交了一些 PR。今年还在 PromCon 上面分享了我们的一些经验。大部分的使用问题已经解决了,还有一个长久以来一直没有解决的问题:大规模的 recording rules, 比如,计算所有容器的 CPU 使用率。本质上,监控系统的数据是一个  OLTP 的问题,大部分场景只需要看到实时的数据就足够了,而且,也很少需要看到所有的 labels 加在一起的维度(只是看某一个 application)。但是我们有一些场景是需要对整个 AZ 做聚合,这有点像 OLAP 了。目前的解决方案是简单地作为一个客户端,去已有的数据里面查出来,然后做整体的计算,存储新的值。这样有很多问题:速度慢;占用太多资源。这个链路是有一些浪费的,raw metrics 首先被收集起来,然后存储到磁盘中,聚合进程再查出来,通过网络拿回来,再进行计算,一来一回消耗了很多磁盘、网络和 CPU 的资源。我在想能否直接让采集端发送 metrics 到聚合段,跳过读写磁盘的逻辑(原来 raw metrics 保存的链路是不变的)。这样或许可以提高一些性能。今年或许可以尝试一下。

做监控的时候也开源了一些项目:

  • promqlpy: 一个 Python 库,可以解析 MetricsQL/PromQL 的语法;
  • mepe: 一个命令行工具,可以 summary 应用暴露出来的 metrics,方便配置监控;
  • metrics-render: 一个 Python 服务,可以根据 url 渲染出来 metrics 的图标,GET 请求,返回 png,这个库还存在一些问题;
  • prometheus-http-sd: 一个给 Prometheus 的监控服务发现系统,支持 yaml/json,可用 Python 脚本方便地对接其他的系统;

这些完成的项目,没有完成的项目也有很多。反思这一年,我发现自己很大一个问题,就是学到什么东西之后急于投入使用,会有很多不成熟的想法,想实验一下行不行,于是会花很多时间做可行性调研,最后可能确定自己的想法是可行的,或者不可行的。但是已经并不重要了,自己这时基本上已经没有激情去实现了…… 这样就花了很多时间,但实际并没有什么产出。好处是可以有一些更有意思的想法,坏处是浪费时间。

所以今年就克制一下自己,除非工作必要,就不开新坑了。要学习的东西虚心去学习,不要急于卖弄学到的东西做出什么来证明自己的能力。

写到这里要穿帮一下,最近几年年终总结没有在年底准时写出来,是因为懒。今年是因为得了新冠。也不知道这几年在新加坡是怎么躲过去的,最近才得。今天(1月12日)终于算是没有症状了,于是开始继续写这篇文章。

对于去年的总结就到这里吧,新的一年,计划如下:

  1. 锻炼身体。年底回到新加坡之后买了两辆自行车跟太太一起到处骑,因为是折叠车,可以用公共交通蛙跳到各个地方去骑车,非常方便。新年就铁人三项:骑车、游泳、跑步,锻炼一个健康的身体吧。
  2. 打字训练:目前打字的速度是50 words per minute. 希望纠正自己的指法,速度提升到至少 70 words per minute.
  3. 学习:年底开始读一些 eBPF 的书,今年学习一下网络、Linux、CS 基础的内容,多总结,希望多写几篇博客。

最近有一个想法:假如喜欢编程这件事情并且想长久地坚持下去的话,比如 30 年,就会发现有些事情是不重要的,有些事情是重要的。比如一年工作的绩效考评,某一年的晋升,等等,放到30年里面,就不那么重要了。有一些事情是重要的,比如花1年时间熟练使用了一个高效的代码编辑器,比如提高代码的输入速度,比如掌握了画出精美的图片的技能,比如能写出通俗易懂的文档和博客的技巧,放到30年的编程生涯中,对于工作和个人的成长就很重要了。

这么一想,打算花 30 年去做一件事情,很多事情就会显的不那么急。我们就会有很多时间去寻找机会,也有很多时间去训练那种长远看来有益的事情。对于一些急功近利的事情也就看的不那么重要了。

说起来画图,我寻找合适的画图工具很多年了。尝试过 dot,(我还是 dot in Jupyter 的作者),OmniGraffle,D2,Mermaid, PlantUML 等等,还是没有一个满意的。就像一些数据库 ER 图 for dev, figma for design, 还缺少一个工具 for SRE. 对于我来说,这个工具应该是:

  1. 基于 text 的,text to diagram
  2. 命令式的,像编程语言一样描述动作。而不是声明式的
  3. 用户在使用的时候,应该快速的将所了解的事实通过这个语言表达出来,而不应该去考虑布局中每一个框的位置和排放,应该减少用户花在画图上的心智负担,将更多的精力放在所要表达的内容上面

过去一年也参考了很多其他的画图工具,也读了一些 DSL 设计有关的论文,今年看能不能把这个语言的设计实现出来。

就写这么多吧,杂七杂八写了很多不相关的东西。


2023年1月12日更新:上文中提到的这个 streaming aggregation metrics 的想法,生病期间朋友告诉我已经 VictoriaMetrics 官方已经实现了。

其他的年终总结列表:

  1. 2013年
  2. 2014年
  3. 2015年
  4. 2016年
  5. 2017年
  6. 2018年
  7. 2019年
  8. 2020年
  9. 2021年
  10. 2022年
 

记录一次问题排查的故事

今年工作中发生的一个问题,因为太简单了,觉得不值得记录。今天读 plantegg 的一篇文章,想起来这件事。技术上很简单,但是故事本身还是挺有意思的。这里尽量客观的记录一下事情经过,因为是当事人,就不做评论了。

故事的起因是,我们提供了一个 HTTP 服务,给不同于我们部门的团队使用,这个服务有些复杂,它本身提供的是 gRPC 服务,但是我们为了给外部不同技术栈的团队使用,做了一个 HTTP 转 gRPC,其他的团队通过公网调用这个 HTTP 服务。

HTTP 再前面就是公司的通用网关了,所以集团外其他用户访问我们的服务链路是 公网 -> 4层网关 -> 7层网关 -> HTTP 转 gRPC 服务 -> 服务本身。

然后有一个 BU,他们说调用我们的服务请求并发提不上去,原因是他们那边的 NAT 端口耗尽了。从他们那边访问这个服务的出口是 客户端 -> NAT 设备 -> 公网 -> … 因为我们只在公网上暴露了 2 个 IP,TCP 的五元组里面 4 个基本已经固定了,2IP+协议+目的端口,所以只有他们 NAT 的端口是一个变量,很快就到了瓶颈。

于是他们工单给我们,要求我们在公网上暴露第二个 IP,以便可以支持更多的 TCP 连接。我们内部讨论之后拒绝了,要求他们使用 HTTP 长链接来调用,而不是短连接。因为他们是作为客户端连续并发调用多次请求,完全是长链接的场景。

第一天,他们测试使用长链接,但是 QPS 高不上去,甚至比原来还低。然后他们让我查一下这个链路上支持不支持长链接,是不是我们的配置有问题。我明确回复支持。

然后他们要求我抓一下网关的包,确认可以支持长链接。我拒绝了。表示对方要先证明不支持长链接,我再去排查。

然后他们继续找经过的中间件团队,要求他们挨个检查是否中间有丢失信息。群里已经有20多个人了,包括对方自己的 NAT 团队,我们的网关团队,我们的 gRPC 团队和服务团队。

第二天,依然要求我们这边去抓包。我依然拒绝在对方没有证明我们这边存在问题的情况系去帮忙排查。然后提供了一个 curl,这个 curl 可以使用同一个 tcp 连接发送 3个请求,可以明确证明链路上都是支持长连接的。命令和输出大体如下:

但是并没有人去运行,这个群里多了很多级别更高的人物。

第三天一早,群里就要开会,拉了很多大佬,要求我加入帮忙排查,我依然拒绝了,我已经证明我们这边是没有问题的,如果要我排查我们的问题,需要对方先证明我们这边存在问题。然后让对方跑一下我昨天发的 curl ,看一下长连接到底可以不可以用。

有人去他们的程序运行的环境中跑了一下,从这个结果可以证明,所有的中间件都没有问题,大概率是他们的程序代码有问题。

下午,定位到 HTTP SDK 的客户端的参数用错了,程序会频繁关闭 TCP 连接。