nginx HTTP Filter 模块

过滤模块基本概念 普通的 HTTP 模块和 HTTP filter 模块有很大的不同。普通模块,例如上篇博客提到的 hello world 模块,可以介入 nginx http 框架的 7 个处理阶段,绝大多数情况下介入 NGX_HTTP_CONTENT_PHASE 阶段,特点是一旦介入了,那么一个 http 请求在这个阶段将只有这个模块处理。 http filter 模块则不同,一个请求可以被任意个 http 过滤模块处理,而且过滤模块 仅处理服务器发出的 HTTP 响应 header 和 body,不处理客户端发来的请求。 过滤链表的顺序 编译 nginx 的第一步是执行 configure 脚本生成 objs/ngx_modules.c 文件,这个文件中的 ngx_modules 数组会保存所有的 nginx 模块,包括普通的 http 模块和本文介绍的 http filter 模块。 nginx 在启动时初始化模块的顺序就是 nginx_modules 数组成员的顺序。因此,只要看 configure 命令生成的 ngx_modules.c 文件就可以知道所有 http 过滤模块的顺序。 对于 http 过滤模块来说,在 ngx_modules 数组中的位置越靠后,实际执行请求时就越先执行。因为在初始化 http 过滤模块时,每个过滤模块都是将自己插入到整个链表的首部。 开发一个 HTTP 过滤模块 一个简单的过滤模块实现这样的功能:在返回的 http response 中,先检查 header,如果是 200 OK,则在 body 中插入一段字符。如下所示: ...

2018-11-19 · Me

nginx 模块开发入门

编写模块 想要学习如何开发一个 nginx 模块,最快速简单的方法莫过于写一个 Hello World 模块,没错,还真有这么一个 nginx-hello-world-module,而且 nginx.org 官网还介绍了这个模块。 首先,对于所有的 nginx 模块来说,都需要实现一个 ngx_module_t 结构体,如下所示,需要特别注意的是,如果去看 module 结构体的定义,它与下面的代码并不是一一对应的,这是因为 NGX_MODULE_V1 宏把其他变量都赋值了,帮我们屏蔽了一些细节。 总而言之,开发一个 nginx 模块,我们跟着这个套路走就行了。 ngx_module_t ngx_http_hello_world_module = { NGX_MODULE_V1, &ngx_http_hello_world_module_ctx, /* module context */ ngx_http_hello_world_commands, /* module directives */ NGX_HTTP_MODULE, /* module type */ NULL, /* init master */ NULL, /* init module */ NULL, /* init process */ NULL, /* init thread */ NULL, /* exit thread */ NULL, /* exit process */ NULL, /* exit master */ NGX_MODULE_V1_PADDING }; 其中第一个变量和最后一个变量都是固定的,我们不需要关心。如果开发的是 HTTP 模块,那么 module type 那儿写上 HTTP 的宏就行了,都是固定死的操作。 ...

2018-11-18 · Me

Wireshark/tcpdump 抓到的数据包可信吗?

table {:toc} 问题的表面现象 问题的背景是这样的: 一个应用程序监听某端口的 UDP 包,发送者发送的 UDP 包比较大,有 65535 个字节。显然,发送者的 UDP 包在经过 IP 层时,会被拆分层多个 1460 字节长度的 IP 分片,经过网络传输之后到达接收方,接收方的网卡收到包后,在内核协议栈的 IP 层又将分片重新组合,生成大的 UDP 包给应用程序。 正常情况下,应用程序能准确无误的收到大 UDP 包,偶尔系统网络流量十分巨大的时候,会丢个别 UDP 包 ——这都在允许范围内。 突然有一天,即便网络流量不是很大的时候,UDP 包的丢包十分严重,应用程序几乎收不到任何数据包。 开始 debug 遇到这样的问题,第一反应有两种可能: 网络不通,数据包没送到网卡。 个别 IP 分片在传输中丢失了,导致接收方无法重组成完整的 UDP 包。 于是用 tcpdump 在 interface eth0 上抓包,出乎意料的是,抓到的 pcap 有完整的 IP 分片。用 wireshark 打开 pcap,wireshark 会自动把 IP 分片重组成 UDP 数据包,检查这个 UDP 包,数据完整无误。 那么现在能断定是应用程序自己出了问题吗? 因为一直以来一个根深蒂固的想法是:既然抓到的 pcap 准确无误,所以数据包已经送到了接收方了,linux 内核只要把分片重组一下交给应用层就可以了,这个过程一般不会出错,所以应用程序没收到只能怪它自己咯? 实际导致问题的原因 最终查明的原因是这台 linux 系统上有两个参数被修改了,当 IP 分片数量过大时,内核中分配给重组的缓冲区已满,导致之后的分片都被丢弃了。 ...

2018-10-24 · Me

nginx 文件锁、自旋锁的实现

在上一篇博客 Linux 共享内存以及 nginx 中的实现的示例中,我们看到每次多个进程同时对共享内存中的 count 加一,导致每次运行结果都不一样,那么解决的方法就是对临界区加锁了,所以本文就来研究一下 nginx 中的几种加锁方式。 文件锁 文件锁的原理就是在磁盘上创建一个文件(操作系统创建一个文件描述符),然后多个进程竞争去获取这个文件的访问权限,因此同一时刻只有一个进程能够访问临界区。 可以看出,进程并不会真正在这个文件中写什么东西,我们只是想要一个文件描述符 FD 而已,因此 nginx 会在创建了文件后把这个文件删除,只留下文件描述符。 多个进程打开同一个文件,各个进程看到的文件描述 FD 值可能会不一样。例如文件 test.txt 在 进程1 中是 101, 而在进程2中是 201 使用文件锁举例 使用文件锁主要用到两个 libc 提供的结构体和函数。 struct flock; 提供一些锁的基本信息,比如读锁 F_RDLCK, 还是写锁 F_WRLCK fcntl(): 对文件描述符进行操作的库函数。 那么如何用这个函数来实现锁的功能呢? 先看一个加锁的代码: void mtx_file_lock(struct fdmutex *m) { struct flock fl; memset(&fl, 0, sizeof(struct flock)); fl.l_type = F_WRLCK; fl.l_whence = SEEK_SET; if (fcntl(m->fd, F_SETLKW, &fl) == -1) { printf("[-] PID %d, lock failed (%s).\n", getpid(), strerror(errno)); } } 再来看一个解锁的代码: void mtx_file_unlock(struct fdmutex *m) { struct flock fl; memset(&fl, 0, sizeof(struct flock)); fl.l_type = F_UNLCK; fl.l_whence = SEEK_SET; if (fcntl(m->fd, F_SETLK, &fl) == -1) { printf("[-] PID %d, unlock failed (%s).\n", getpid(), strerror(errno)); } } 遇到临界区,只要在需要进行保护的代码前后加上上面的 lock() 和 unlock() 函数就可以了。 ...

2018-09-18 · Me

Linux 共享内存以及 nginx 中的实现

共享内存方法简介 Linux/Unix系统中,共享内存可以通过两个系统调用来获得,mmap 和 shmget/shm_open,其中 shmget 和 shm_open 分别属于不同的标准: POSIX 共享内存(shm_open()、shm_unlink()) System V 共享内存(shmget()、shmat()、shmdt()) shmget 和 shm_open 类似的地方在于都是创建一个共享内存,挂载到 /dev/shm 目录下,并且返回一个文件描述符,fd。 区别是 POSIX 没有提供将 fd 映射到进程地址空间的方法,而 System V 方式则直接提供了 shmat(),之后再 nginx 的实现中会再次看到。 mmap 语义上比 shmget 更通用,因为它最一般的做法,是将一个打开的实体文件,映射到一段连续的内存中,各个进程可以根据各自的权限对该段内存进行相应的读写操作,其他进程则可以看到其他进程写入的结果。 而 shmget 在语义上相当于是匿名的 mmap,即不关注实体文件,直接在内存中开辟这块共享区域,mmap 通过设置调用时的参数,也可达到这种效果,一种方法是映射/dev/zero 设备,另一种是使用MAP_ANON选项。 mmap() 的函数原型如下,具体参数含义在最后的参考资料中给出。 void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset); nginx 中的实现 nginx 中是怎么实现的呢? 我们看一下源码 src/os/unix/ngx_shmem.c。 一目了然,简单粗暴有木有! 分三种情况 如果mmap系统调用支持 MAP_ANON选项,则使用 MAP_ANON 如果1不满足,如果mmap系统调用支持映射/dev/zero设备,则映射/dev/zero来实现。 如果1和2都不满足,且如果支持shmget的话,则使用该shmget来实现。 #if (NGX_HAVE_MAP_ANON) ngx_int_t ngx_shm_alloc(ngx_shm_t *shm) { shm->addr = (u_char *) mmap(NULL, shm->size, PROT_READ|PROT_WRITE, MAP_ANON|MAP_SHARED, -1, 0); return NGX_OK; } #elif (NGX_HAVE_MAP_DEVZERO) ngx_int_t ngx_shm_alloc(ngx_shm_t *shm) { fd = open("/dev/zero", O_RDWR); shm->addr = (u_char *) mmap(NULL, shm->size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); return (shm->addr == MAP_FAILED) ? NGX_ERROR : NGX_OK; } #elif (NGX_HAVE_SYSVSHM) ngx_int_t ngx_shm_alloc(ngx_shm_t *shm) { id = shmget(IPC_PRIVATE, shm->size, (SHM_R|SHM_W|IPC_CREAT)); shm->addr = shmat(id, NULL, 0); if (shmctl(id, IPC_RMID, NULL) == -1) { } return (shm->addr == (void *) -1) ? NGX_ERROR : NGX_OK; } 上面代码中的宏定义(比如 NGX_HAVE_MAP_ANON )是怎么来的呢?编译 nginx 源码之前的一步是运行 configure 文件,它会调用 auto/unix 脚本 ,该脚本会写一端测试程序来判断相应的系统调用是否支持,如果支持,则在自动生成的 objs/ngx_auto_config.h 文件中定义对应的宏。 ...

2018-09-04 · Me

使用 socketpair 实现进程间通信

socketpair 牛刀小试 int socketpair(int d, int type, int protocol, int sv[2]); 第1个参数d,表示协议族,只能为 AF_LOCAL 或者 AF_UNIX; 第2个参数 type,表示类型,只能为0。 第3个参数 protocol,表示协议,可以是 SOCK_STREAM 或者 SOCK_DGRAM AF_UNIX 指的就是 Unix Domain socket,那么它与通常网络编程里面的 TCP socket 有什么区别呢? 查阅了资料后发现: Unix Domain socket 是同一台机器上不同进程间的通信机制。 IP(TCP/IP) socket 是网络上不同主机之间进程的通讯机制。 socketpair() 只支持 AF_LOCAL 或者 AF_UNIX,不支持 TCP/IP,也就是 AF_INET, 所以用 socketpair() 的话无法进行跨主机的进程间通信。 先看一个简单的示例: int main() { int fd[2], retpid; int pid , status; char input[MAX_LEN]; if (socketpair(AF_UNIX, SOCK_STREAM, 0, fd) < 0) { printf("call socketpair() failed, exit\n"); return -1; } pid = fork(); if (pid) { /* parent */ printf("Parent process, pid = %d\n", getpid()); while (1) { fgets(input, MAX_LEN, stdin); write(fd[0], input, MAX_LEN); } } else { /* child */ printf("Child process, pid = %d\n", getpid()); int nread = 0; while (1) { nread = read(fd[1], input, MAX_LEN); input[nread] = '\0'; printf("Child: nread = %d, data = %s\n", nread, input); } } retpid = wait(&status); if (retpid) { printf("Parent: reap child process pid = %d\n", retpid); } return 0; } 编译后运行,可以看到每次在终端输入信息,子进程都会回显到屏幕上。 ...

2018-08-28 · Me

BPF -- Linux 中的 DTrace

记得 5 年前刚接触 perf 的时候,还特意调研了一下不同系统上的动态和静态追踪工具,知道了 Linux 上的 SystemTap,perf。Solaris 上的 DTrace。看到绝大多数资料都说 DTrace 多么的强大好用,但是 Linux 却没有与之相提并论的工具。 最近看到 BPF 这三个字被提及的很频繁,搜索了一下发现它号称 “Linux 中的 DTrace”, 于是试着玩了一下。 BPF 全称是 “Berkeley Packet Filter”,字面意思是包过滤器,那么问题来了:我一个包过滤器,怎么就成了追踪调试工具呢? 这主要是因为一些历史的进程:原先开发 BPF 的目的是在内核重定向数据包,接着增加了对事件的追踪功能,然后又增加了基于时间的采样,于是久而久之 BPF 就成了一个功能强大的调试工具。 安装 首先,内核版本最好大于 4.9 , 可以用 uname -a 命令查看。 其次,查看一下内核在编译的时候是否开启了 BPF 选项,一般在 /boot/ 目录下有对应内核版本的 config 文件,比如在我的机器上是 /boot/config-4.15.0-30-generic。 如果看到 CONFIG_BPF_SYSCALL=y 说明可以用 BPF 的基本功能。 前面提到 BPF 号称 Linux 中的 DTrace,为什么呢? 因为 DTrace 包含了一个类似脚本语言的 D 语言,用户可以用简单的几句 D 语言完成复杂的调试追踪任务,这一点是 perf 做不到,而 BPF 做到了。 确认了内核支持 BPF 之后,我们可以安装一个叫做 bcc 的工具,通过它可以方便的使用 BPF。 ...

2018-08-09 · Me

Aho–Corasick 算法,AC 自动机

AHO 算法,或者叫 AC 自动机、又或者叫 Aho–Corasick string matching algorithm,是一个高效的多模式匹配算法,它的特点是可以同时匹配多个模式串。 最常见的应用就是病毒扫描 : 把所有病毒的特征码(类似一段字符串)构造成一个 AC 自动机,把用户的文件或者网络数据流作为输入文本,只要在输入文本中找到了任何一个特征码,那么就表示有病毒存在。 对于这样的一个应用场景,我们最希望的功能就是只扫描一遍输入文本,找出所有的病毒,而 AC 自动机恰恰就有这样的能力。 一般使用 AC 自动机需要以下三步: 根据待匹配的字符串 P1, P2, Pn 建立 Trie 给 Trie 添加失败路径,实际上是生成了一个自动机。 将输入文本 str 的字符逐个通过自动机,在 O(n) 的时间复杂度内找出 P1, P2 … Pn 是否存在于 str 内。 举例 假设我们有模式字符串 { fat, fare, hat, are }, 输入的文本为 “fatehatfare”。 显然,所有的模式串都出现在了我们的输入文本中。我们的目标就是只扫描一遍文本串,找出所有的模式串。 建立 Trie 首先根据模式串建立起一个 trie,一般来说每个节点的结构大概是这样: Node { Node * children[26]; /* 指向子节点的指针 */ Node * parent; /* 指向父节点的指针 */ Node * fail; /* 失败指针 */ bool terminate; /* 是否是一个终结节点,即一个串的最后一个字符 */ } Trie 建立好之后大概是这个样子: ...

2018-07-14 · Me

SO_REUSEPORT 和 epoll 的 Thundering Herd

SO_REUSEPORT 顾名思义就是重用端口,是指不同的 socket 可以 bind 到同一个端口上。 Linux 内核 3.9 版本引入了这个新特性,有兴趣的同学可以移步到这个链接查看更加详细的内容。 https://lwn.net/Articles/542629/ Reuse Port 我们先通过一段简单的代码来看看怎么使用这个选项(完整的代码在这里下载)。 int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 一定要在 bind() 函数之前设定好 SO_REUSEPORT setsockopt(serv_sock, SOL_SOCKET, SO_REUSEPORT, &enable, sizeof(int)); bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); listen(serv_sock, 20); accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size); 将上面的代码编译生成两个可执行文件,分别启动运行,并监听相同的端口。 ./port_reuse1 127.0.0.1 1234 再用 telnet/nc 等工具发送请求到 1234 端口上,多重复几次,会看到两个进程轮流的处理客户端发来的请求。 这里说一个题外话,上面的例子是手动启动两个进程。而我发现如果是进程自动 fork() 生成 2 个进程的话,似乎不用设置 SO_REUSEPORT 也能自动监听同一个端口。这是为什么? Thundering Herd / 惊群现象 The thundering herd problem occurs when a large number of processes waiting for an event are awoken when that event occurs, but only one process is able to proceed at a time. After the processes wake up, they all demand the resource and a decision must be made as to which process can continue. After the decision is made, the remaining processes are put back to sleep, only to all wake up again to request access to the resource. ...

2018-06-23 · Me

如何拦截库函数调用 ?

LD_PRELOAD 环境变量 : 直接作用在可执行文件上 (准确的说是拦截库函数) 。 ptrace() : 拦截子进程的系统调用。 1. LD_PRELOAD LD_PRELOAD 的优势: 使用简单。 不需要修改被拦截程序的源码。 例如我想拦截程序 A 所有调用 malloc() 的地方,那么程序 A 不需要任何修改,只要准备好自己的 malloc() 函数,编译成动态链接库 .so 文件,然后在运行 A 之前先用 LD_PRELOAD 设定好环境变量就可以了。 LD_PRELOAD 的原理就是链接器在动态链接的时刻,优先链接 LD_PRELOAD 指定的函数。准确的说 LD_PRELOAD 拦截的是动态库中的函数,但是一般我们写的应用程序都是通过库函数来调用系统调用 API,所以 LD_PRELOAD 也间接的拦截了系统调用。 说到这里,LD_PRELOAD 的缺点也非常明显,它只能作用于动态链接库,要是静态链接的就没戏了。 腾讯的 C++ 协程库 libco,以及 tcmalloc 的 TC_MALLOC 都用到了这种方式。 2. ptrace() ptrace 是 linux 内核原生提供的一个功能,因此功能比 LD_PRELOAD 强大的多。它最初的目的是用来 debug,例如大名鼎鼎的 gdb 就是依赖于 ptrace。 要使用 ptrace 拦截程序 A 的系统调用,有两种方法: ptrace 一个新进程:在代码中 fork 一个子进程,子进程执行 ptrace(PTRACE_TRACEME, 0, 0, 0)函数,然后通过 execv() 调用程序 A。 attach 到已运行的程序 A :执行ptrace(PTRACE_ATTACH, pid, 0, 0)。 以上两种方式,ptrace 都会拦截发送到 A 进程的所有信号(除 SIGKILL 外),然后我们需要自己选择哪些系统调用需要拦截,并在拦截后转到我们自己的处理函数。 ...

2018-06-03 · Me