看mockeagain库如何模拟龟速网络

出于测试网络程序的稳定性,或者复现bug的需要, 开发软件过程中不时地有模拟特定网络环境的需要, 以提高程序健壮型或者提高定位问题的速度等。

StackOverflow上就有不少关于如何模拟龟速网络的提问:

既然是模拟网络环境,很容易想到的一种方法是通过专门的软件组建网络, 然后通过配置软件的不同参数达到模拟特定网络的目的。

网络模拟软件

此类软件一般位于通信两端点的中间,有能力对连接做手脚,从而模拟出特定的网络。

比如Linux上实现在内核中的netem, 其应用层控制命令是tc,Gist上就有人整理了一个bash脚本: Simulates a slow bandwidth, high latency network - GitHub Gist

模拟软件的方法比较直觉(intuitive),功能强大,但一般也比较难部署。

那有没有轻量级的方案呢?从编程角度看,收发数据包无非就是通过send/recv等系统调用完成的, 那如果我们能够对这些系统调用做些手脚,改变它们的行为,那么不也可以达到“模拟”的目的吗?

mockeagain就是这样一个实现,当我第一次在OpenResty仓库中翻到它时,我的内心: 原来还可以这样,awesome!

mockeagain

mockeagain 是春哥(agentzh)为测试OpenResty而实现的一个模拟极端慢速网络(每次poll之后只能读写一个字节)的动态库。 它就是利用了LD_PRELOAD机制,实现对网络程序中常用的读写套接字接口进行模拟(mock)。

This tool emulates an ideally slow network by mocking the polling and read/write syscalls exposed by glibc via the LD_PRELOAD technique.

相比前面提到的特定模拟软件,此库非常轻量,部署简单方便。

LD_PRELOAD简介

简单来说,LD_PRELOAD就是Linux系统提供了一种动态库加载的运行时机制,可以实现很方便的替换掉动态库中的函数。 只要在执行程序之前,设置好环境变量LD_PRELOAD即可。 一般会利用此功能实现两种功能:

  1. 替换目标函数,从而控制目标程序逻辑,姑且称之为bypass。
  2. 对目标函数进行封装,以实现特定功能。(wrap)

以下是我搜集的如何实现替换库函数的文章,可以看作是教程了。

  1. Modifying a Dynamic Library Without Changing the Source Code Greg KH 2004年发表在LJ上的文章。

  2. How to wrap a system call (libc function) in Linux 提供了两种方法,一种是LD_PRELOAD,另一种是ld的wrap选项,需要重新链接目标程序(很多场景可能无法接收此要求)。

另外大名鼎鼎的内存检查工具,程序员的好帮手valgrind,也是依靠LD_PRELOAD机制, 在malloc/free等函数之上实现了一层封装(wrap),以检测是否存在内存泄漏等问题。

The Design and Implementation of Valgrind 中提到:

Valgrind is compiled into a Linux shared object, valgrind.so, and also a dummy one, valgrinq.so, of which more later. The valgrind shell script adds valgrind.so to the LD_PRELOAD list of extra libraries to be loaded with any dynamically linked library. This is a standard trick, one which I assume the LD_PRELOAD mechanism was developed to support.

LD_PRELOAD一方面提供了灵活的替换库函数,另一方面也带来了很大的安全隐患。 看这篇文章(bypass): 警惕UNIX下的LD_PRELOAD环境变量 by 陈皓

mockeagain实现分析

本次分析基于git代码版本:b353eb1。

mockeagain目前对以下接口作了封装:

  • 读接口 read/recv/recvfrom

  • 写接口 writev/send

  • 多路复用接口 poll

  • 创建套接字接口 socket/accept4

  • 关闭套接字 close

需要mock创建和关闭套接字接口的原因在于,需要对库内部的状态变量进行创建资源和释放资源等。

封装(wrap)

封装就需要拿到原来glibc中的对应符号的地址,这个就是用的dlsym,普普通通的。 但是第一个参数却是RTLD_NEXT,这是什么呢?查看dlsymde manpage,其中有提到:

There are two special pseudo-handles, RTLD_DEFAULT and RTLD_NEXT. The former will find the first occurrence of the desired symbol using the default library search order. The latter will find the next occurrence of a function in the search order after the current library. This allows one to provide a wrapper around a function in another shared library.

RTLD_NEXT是伪句柄,此时这样查找对应符号:在库顺序列表中,从当前库的下一个库开始查找, 找到第一个符号就算完,如果都没有,那dlsym只能返回NULL了。

不难发现,这种方法我们无法在编程是保证查找到的符号在哪个库中,因为没有明确的说明。 不过,ldd命令可以看到程序的库搜索列表顺序,这样排查问题时就能提供一些线索。

比如下边的测试命令,此时mockeagain.so中的dlsym(RTLD_NEXT, "socket"); 就会依次在libc, libdl, ld-linux这三个库中查找socket符号。

$ env LD_PRELOAD=$PWD/mockeagain.so ldd poll
    linux-vdso.so.1 (0x00007ffccbada000)
    /tmp/mockeagain.so (0x00007f0f8f118000)
    libc.so.6 => /lib64/libc.so.6 (0x00007f0f8ed39000)
    libdl.so.2 => /lib64/libdl.so.2 (0x00007f0f8eb35000)
    /lib64/ld-linux-x86-64.so.2 (0x000055efd5ef5000)

关于dlsym()与RTLD_NEXT配合使用,可能会出现意想不到的bug,具体请看: Dangers of using dlsym() with RTLD_NEXT

库配置

软件一般都有相应的配置,以满足不同的需求。 对于动态库来说,配置项相对比较少,且使用配置文件不够灵活,所以一般使用环境变量进行配置,mockeagain亦不例外。 一共就三个配置。

  1. MOCKEAGAIN开关

    配置是否对读写进行mock,包含r/R字符表明对读接口进行mock; 相应地w/W字符对写接口进行mock。

  2. MOCKEAGAIN_VERBOSE

    设置为0-9表示输出多点日志。

  3. MOCKEAGAIN_WRITE_TIMEOUT_PATTERN

    设置并触发以后,对应的套接字就再也得不到写入数据的机会了(永远timeout), 因为poll结果集无论如何都把它排除了。

    比如设置为"foo",当往套接字写入foo后,之后就再也无法写了 (当然前提是应用程序使用poll并“尊重”其返回的结果)。

内部数据

mockeagain只会受理值在1024(包括)以内的套接字,因为mockeagain主要用在开发测试过程中, 处理太大的fd意义不大。

  • matchbufs

    保存最近写入的部分数据,与pattern进行对比,从而判断是否需要永远timeout。 snd_timeout_fds[]标记为1表明对应fd被“判死刑”了。

    matchbufs[]每个槽的内存是在writev/send中分配的。

  • polled_fds[]

    保存每个fd的poll结果,如果上次poll返回结果集中某个fd,则设置其标记为1; 同时把对应的poll事件(POLLIN/POLLOUT)放在active_fds[]

  • weird_fds[]

    值为1表明fd是非TCP套接字,其行为不受mockeagain库影响。

  • written_fds[]

    值为1表明有数据写入此fd。

social