最近在做网关相关工作,和Nginx关系比较密切,故了解下它的具体实现。用cloc简单看了下,Nginx总共的C代码不到10W行,基本和Redis差不多,可以说是非常少了,而且Nginx的代码非常经典,以快速和高效著称,值得一试。考虑到Nginx的功能太基础,大多数国内互联网公司都使用的是淘宝开发的基于Nginx扩展出来的Tengine,其中包括负载均衡算法支持vnswrr(虚拟节点平滑权重轮询),以及主动healthcheck功能(Nginx的healthcheck功能需要购买Nginx Plus才可以使用),因此以Tengine作为分析对象。
一、环境准备
先运行下面的命令,下载和配置编译
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
cd ~/software wget https://github.com/openssl/openssl/archive/refs/heads/master.zip unzip master.zip cd openssl-master ./config -d make
cd ~/software
wget https://github.com/alibaba/tengine/archive/refs/heads/master.zip unzip master.zip cd tengine mkdir -p workspace/logs
cp -rf html workspace/
brew install pcre -y ./configure --prefix=./workspace --with-debug --add-module=./modules/ngx_http_upstream_vnswrr_module --add-module=./modules/ngx_http_upstream_check_module --with-openssl=/Users/danwu/software/openssl-master
|
把生成的Makefile中default改成all(或者修改Clion的preference中Makefile的build target从all改成default)
然后,使用Clion打开Makefile文件,选择Open as Project,注意要把弹出的Clean命令勾选去掉,不然执行clean就把Makefile删掉了。
点击左上角绿色的锤子执行编译,通过编译日志中的-g选项可以确认是带debug信息的编译。
在conf/nginx.conf配置文件如下,其中master_process off能确保单线程运行(不添加worker),这个是可以debug的关键。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| worker_processes auto;
daemon off;
master_process off;
error_log logs/error.log; error_log logs/error.log notice; error_log logs/error.log debug; error_log "pipe:rollback logs/error_log interval=1d baknum=7 maxsize=2G";
pid logs/nginx.pid;
events { worker_connections 1024; }
http { include mime.types; default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main; access_log "pipe:rollback logs/access_log interval=1d baknum=7 maxsize=2G" main;
sendfile on;
keepalive_timeout 65;
upstream hello { vnswrr; server 127.0.0.1:9090 weight=5; server 127.0.0.1:9090 weight=2; check interval=3000 rise=2 fall=5 timeout=1000 type=http; check_keepalive_requests 100; check_http_send "HEAD /healthcheck HTTP/1.1\r\nHost: localhost:9090\r\n\r\n"; check_http_expect_alive http_2xx http_3xx; }
server { listen 9999; server_name localhost;
location / { root html; index index.html index.htm; }
error_page 500 502 503 504 /50x.html; location = /50x.html { root html; }
location /hello { proxy_pass http://hello; } } }
|
编辑Run -> Edit Configurations,选择target为all或者default/build,然后Executable为obj/nginx,添加启动参数
1
| -c /Users/wudan03/software/tengine/conf/nginx.conf
|
为了让upstream生效,我们起一个golang的http server
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| package main
import ( "fmt" "log" "time" "net/http" )
func helloHandler(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) fmt.Fprintf(w, "Hello World!") }
func healthcheckHandler(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) fmt.Fprintf(w, "Ok!") }
func main() { fileServer := http.FileServer(http.Dir("./static")) http.Handle("/", fileServer) http.HandleFunc("/hello", helloHandler) http.HandleFunc("/healthcheck", healthcheckHandler)
fmt.Printf("Starting server at port 9090\n") if err := http.ListenAndServe(":9090", nil); err != nil { log.Fatal(err) } }
|
然后在ngx_http_request.c:ngx_http_process_request方法中打断点,并点击绿色甲壳虫开始debug,使用下面的curl触发调用
1
| curl "http://127.0.0.1:9999/index.html"
|
通过上述curl请求验证程序在断点处停止执行
二、http请求处理流程
首先需要明确几个基本概念
a. 多进程/多线程
Nginx使用1个Master + N个Worker的方式运行,Worker数量一般为CPU数量,Worker是通过fork创建出来的(线程是pthread_create创建出来的),这里使用进程而不是线程的原因是,地址空间是隔离的,不会互相影响,不存在竞争,因为线程之间是可以互相共享数据的,典型的例子是负载均衡如下一篇讲到的round robin算法,每次都要记录轮询到了哪个位置,如果是并发的就需要加锁,性能就会很差。
问:Redis为什么不能像Nginx一样多线程?
答:Nginx本身是stateless的,不存储实际的数据,数据都来自配置文件,可以每个进程维护一份,而Redis本身需要读写数据,如果多进程,实际是需要加锁的。我们可以通过在一台机器上部署多个Redis实例来充分利用CPU
b. NIO(epoll/kqueue)
Nginx使用的是经典的事件驱动的NIO模型,Linux下epoll/macOS下kqueue来处理外来请求,并通过hander来绑定事件触发之后的回调函数。
c. socket
bio socket server的典型流程是:
- 调用socket系统调用,创建一个fd(File Descriptor)
- 调用bind系统调用,将第一步创建的socket绑定到本地的端口
- 调用listen系统调用,开始监听端口上新来的连接
- 在死循环中调用accept系统调用,会阻塞知道有新的连接过来,会返回一个新的fd,程序创建新的线程或者fork新的进程,在里面处理这个fd上的数据读写
这个过程只是要说明一件事,监听端口的fd和新进来的请求的fd不是同一个,因此在监听端口的fd的读操作的handler中处理新连接即可,然后在新请求的fd的读写操作的handler中分别做读写数据的处理
Nginx处理upstream请求的大致流程如下图

整个流程通过NIO eventloop连接起来,不是太好串联,其中Step8应该是从eventloop中出来的,图里画不下了。
其中Step7主要是发请求给proxy,而Step8主要是将proxy发回的数据写回Downstream(即连到nginx的client)
三、总结
整理的流程非常复杂,但是我们只要抓到设置handler的地方就能找到下个事件的处理函数,同时要紧跟添加事件的地方。整个代码还包含了缓存、过滤等逻辑,以及处理静态文件和其他协议、以及https的请求的过程,这里只介绍了个大概,按需处理,后面两期分别介绍目前比较关注的负载均衡和healthcheck机制。
四、Reference
- Tengine
- linux下gcc编程10-clion编译调试nginx
- Nginx源码分析 - HTTP模块篇 - HTTP Request解析过程
- Nginx源码分析