(翻译) Nginx架构

01 Mar 2016

原文地址

14.2 nginx 架构总览

传统的网络编程模型是, 每个进程/线程处理一个请求, 在网络读写会阻塞住. 这种编程模型, 在某些应用场景, 不能有效利用起内存及CPU资源. 因为创建一个进程/线程的内存开销很大, 包括栈以及堆内存的分配, 执行上下文的建立等. 进程/线程创建/销毁, 也带来了额外的CPU的开销. 此外, 当进程/线程数过多时, 频繁的上下文切换的开销, 会导致争用的问题, 从而导致性能不佳. 这些复杂性在Apache之类的Web服务上充分体现了出来. 我们需要在所提供功能的丰富性, 和资源的有效利用之间, 找到平衡.

nginx从项目伊始, 目标就是, 在满足站点流量不断增长的情况下, 达到更好的性能, 更有效地利用服务器资源. 所以, nginx采用了截然不同的编程模型. nginx的开发受到了最近系统内核开发中, 基于事件的机制的启发. 其结果就是, 模块化, 事件驱动的, 异步的, 单线程, 非阻塞, 奠定起了nginx代码架构的基调.

nginx充分利用了多路复用, 以及事件通知机制, 并将特定的任务分发给不同的进程去执行. 请求被固定数目的worker进程处理. 每个worker进程, 每秒可以同时处理数以千计的请求.

代码结构

worker囊括了最核心最基础的各个模块. nginx的核心, 是维护一个简短有效的执行循环, 将处于不同阶段的请求, 交由各个模块代码去处理. 表现层以及应用层的功能逻辑绝大部分由各个模块提供. 模块的功能包括: 从网络/磁盘读写数据; 对内容做处理; 输出过滤; 执行服务器端引用操作; 以及在代理的情形下, 将请求转发给上游服务(upstream).

nginx的模块化架构, 允许开发者, 不改动核心代码, 就可以扩展Web服务器的功能. nginx模块分成了几类: 核心模块, 事件模块, 阶段处理模块, 协议模块, 变量处理模块, 过滤模块, 上游模块, 负载均衡模块等. 不同类型的模块的细节, 在后文会详细介绍. 目前nginx不支持模块动态加载, 也就是说, 模块必须在编译的时候引入. 将来的版本计划会对模块动态加载提供支持.

在各种事件处理(接受请求, 处理请求, 管理连接, 内容获取等)的实现上, nginx尽可能利用了系统内核提供的各种事件通知机制, 及各种磁盘读写优化手段, 如kqueue, epoll, event ports等. 目的是在网络异步读写, 磁盘操作, socket读写, 超时机制等方面, 尽可能的利用系统内核所提供的优化机制. 我们针对每个基于Unix的系统内核, 尽可能地优化了多路复用, 异步IO的处理方式.

下图是一个简略的nginx架构图:

Worker 模型

如前所述, nginx不会为每一个请求创建进程/线程. worker进程, 从一个共享的监听socekt, 接受新的请求, 并在 进程内一个非常高效的执行循环中, 去负责上千个请求的处理. nginx本身没有特别的请求仲裁/分发机制, 这一环节是交由系统内核去处理的. nginx一启动就创建了多个监听socket. worker进程不断地从这些socket接受请求, 负责读写, 处理HTTP请求和响应.

worker进程最复杂的代码块, 是上述的这个执行循环. 它囊括了各种内部调用, 并以异步事件的方式去执行. 异步操作是通过模块化, 事件通知机制, 回调函数, 以及精细控制的定时器实现的. 总的来说, 最重要的想法就是, 尽可能的非阻塞操作. 事实上, 只有当磁盘性能不足是, 才会发生阻塞的情形.

因为不会为每个请求创建进程/线程, 所以, 在绝大部分情况下, nginx对内存的使用是非常节省有效的. 由于省去了频繁创建销毁的开销, nginx对CPU的使用同样节省. nginx仅仅做了这样一件事: 检查网络及存储状态, 将新的请求初始化, 丢入到执行循环中去, 异步执行直到完成, 请求一旦结束, 就会被销毁, 并从执行循环中移除. 此外, 通过对syscall的谨慎使用, 对象池及内存分配器的精确实现, 即便在很高的流量情形下, nginx也实现了对CPU的低负载.

nginx用多个worker处理请求, 所以在多核系统上有很好的扩展性. 一般来说, 每个计算核心分配一个worker, 可以充分利用多核架构的优势, 并且避免了争用及锁的问题. 资源控制, 分拆到了每个单线程worker的进程中去做, 因此避免了资源干等的情形. 这种模式也能在多磁盘的情形下, 避免IO阻塞, 达到更好的磁盘利用率. 因此, 当流量被分摊到多个worker时, 服务器的资源能够得到更有效的利用.

但是对于某些磁盘及CPU使用场景, worker的数目需要调整一下. 这里我们给的建议比较简单, 系统管理员应该针对自己的业务场景, 尝试多种配置方案. 比较通用的建议: 如果是计算密集型的业务 (如, 非常多的TCP/IP协议栈相关处理, SSL, 或者压缩), 那么worker数应该和核心数相等; 如果是IO密集型的业务 (如, 非常多的静态资源请求, 代理请求), 那么worker数应该在核心数的1.5到2倍之间. 有些工程师, 根据独立的存储单元数目, 决定worker数, 但是这种策略的有效性依赖于磁盘存储的类型及配置.

在下一个版本里, nginx的开发人员要去解决的一个主要问题就是, 如何避免绝大部分的阻塞磁盘IO操作. 目前, 如果磁盘性能不能满足一个worker的存储请求时, 这个worker任然有可能在磁盘读写时阻塞. 我们有一系列机制及配置项, 去缓解这个导致磁盘IO阻塞的点. 其中最值得一提的是, AIO以及sendfile等选项, 给磁盘性能提供了不少优化空间. 一个nginx的实例配置, 应该根据数据量, 可用内存大小, 以及底层的存储架构, 来做规划.

另一个和worker相关的问题是, 对于嵌入式脚本的有限支持. 举例来说, 标准nginx发行版只支持Perl脚本嵌入. 原因很简单: 一个嵌入脚本有可能在任何一个操作上阻塞, 或者异常退出, 其中任何一种情况, 都会导致worker挂起, 从而一下子影响了该worker所正在处理的上千个链接. 我们计划让nginx脚本嵌入, 更简单, 更健壮, 从而适用到更加广泛的应用场景中去.

nginx 进程角色

nginx由多个进程构成: 一个master进程以及多个worker进程; 此外, 还有多个特定用途的进程, 如加载进程, 缓存管理进程. 在1.x版本系列里, 每个进程都是单线程的. 所有进程主要通过共享内存的机制实现夸进程通讯. master进程以root用户执行. 其它进程以普通用户权限执行.

master进程负责如下几件事情:

worker进程接受/处理来自客户端的连接, 提供反向代理及过滤功能等, 几乎其它nginx所提供的功能. 就nginx实例监控而言, 系统管理员需要关注worker进程, 因为它们才反映了Web服务每日的运作情况.

缓存加载进程负责检查落地到磁盘的缓存对象, 并负责将缓存元数据加载到nginx的内存数据库中. 本质上来说, 缓存加载进程让nginx能够对一个特别分配的目录结构下的文件进行处理. 缓存加载进程遍历这个目录, 检查缓存元数据, 在内存数据库里更新相关记录, 当所有信息有效可用时, 这个进程就结束了使命.

缓存管理进程主要负责缓存的过期和标记失效. 该进程在nginx运行时长期驻留, 在运行失败的时候会被mater进程重启.

14.4 nginx 内部实现

如前所述, nginx代码由了内核以及多个模块组成. 内核提供Web服务器, Web及邮件反向代理的基础功能; 内核提供了底层网络协议支持, 构建了必要的运行时环境, 保证了不同模块间的无缝交互. 但是, 绝大部分协议及应用相关的特性, 都是通过模块, 而不是内核实现的.

内部实现上, nginx以模块流水线的方式处理连接. 换言之, 对于每个操作, 都有对应的模块去负责; 例如, 压缩, 内容替换, 执行服务端引用, 通过FastCGI/uwsgi等协议, 和上游应用服务器交互, 以及和memcached交互等.

有些模块, 如httpmail, 处于内核以及真正完成工作的模块之间. 这两个模块提供了对内核以及底层组件的封装. 在这两个模块中, 实现了和特定协议相关的一系列事件的处理, 如HTTP, SMTP, IMAP等协议. 和nginx内核一起, 这些上层模块负责维护具体功能模块的调用顺序. 虽然HTTP模块目前是在http模块实现的, 考虑到未来对SPDY等协议的支持, 我们计划将这部分隔离到一个具体功能模块去实现.

功能模块可以被划分为事件模块, 阶段处理模块, 协议模块, 变量处理模块, 过滤模块, 上游模块, 负载均衡模块等. 绝大部分功能模块补充了nginx的HTTTP功能, 不过事件模块以及协议模块在mail模块也被用到. 事件模块提供了系统相关的时间通知机制, 如kqueue, epoll, 依赖具体系统所提供的功能以及构建配置. 协议模块允许nginx使用HTTPS, TLS/SSL, SMTP, POP3及IMAP等协议通讯.

一个典型的HTTP请求处理循环:

  1. 客户端发送HTTP请求
  2. nginx内核根据配置的location匹配请求, 选择适当的阶段处理模块
  3. 如果有的话, 负载均衡器选择一个上游服务器, 作请求代理
  4. 阶段处理模块完成它的工作后, 将输出传送给其第一个过滤模块
  5. 第一个过滤模块将其输出传送给第二个过滤模块
  6. 第二个过滤模块将其输出传送给下一个过滤模块, 如此反复
  7. 最终的响应结果被发送到客户端

nginx模块的调用是高度可配置的. 整个过程是通过一系列函数指针的回调实现的. 当然, 这个模式也对自己开发模块的程序员带来了很大的负担, 因为需要严格定义其模块何时, 以及如何被调用. 为了缓解这个麻烦, nginx的API以及开发者文档都在不断的改进.

模块可以挂载的地方举例:

再一个worker的执行循环内, 负责处理一个请求的一系列事件处理, 大致如下:

  1. 开始调用ngx_worker_process_cycle()
  2. 处理系统相关的事件, 如epoll, kqueue
  3. 接受事件并派发相关事件
  4. 处理或者代理请求头部和内容
  5. 生成相应内容(头部, 内容), 以数据流的方式发送给客户端
  6. 结束请求
  7. 重新初始化各种计时器及事件

执行循环(5 -6 步)保证了请求的增量生成, 并以流的方式及时发送给客户端.

更细一点, 处理一个HTTP请求的步骤大概是这样的:

  1. 初始化请求处理
  2. 处理请求头部
  3. 处理请求内容
  4. 调用相关的处理器
  5. 按照处理阶段的顺序, 一步步执行下来

这里引出了处理阶段的概念. 当nginx处理一个HTTP请求时, 会把请求交到一些列处理阶段中去. 在每个处理阶段, 都有处理器需要执行. 总的来说, 阶段处理器处理请求, 产生相关的输出. 阶段处理器是和配置里面的location定义关联在一起的.

绝大多数情况下, 阶段处理器做四件事情: 拿到location配置, 生成响应, 发送头部, 以及发送内容. 每个处理器有一个描述请求的结构体参数. 请求结构体中有非常多的关于客户端的信息, 如请求URI, 以及头部等.

当读完HTTP请求的头部时, nginx查找对应的虚拟server配置. 如果找到, 请求会经历如下六个阶段:

  1. server重写阶段
  2. locaton匹配阶段
  3. loactoin重写阶段 (有可能会跳转回上一步)
  4. 访问权限决议阶段
  5. 尝试文件返回阶段
  6. 打日志阶段

对于进来的请求, 为了生成相应所需的内容, nginx将请求交给合适的内容处理器去处理. 基于具体的location配置, nginx可能会先尝试无调件处理器, 如perl, proxy_pass, fly, mp4 等. 如果上述内容处理器无一符合, nginx会按照如下顺序, 逐一选择: ramdon index, index, autoindex, gzip_static, static.

索引模块的细节在nginx文档中有详述, 概括一下就是, 索引模块处理路径带/后缀的请求. 如果一个特定的模块 (如 mp4autoindex) 不匹配, 请求的内容视作磁盘上的一个普通文件或者目录, 并由static 处理器处理. 对于一个目录路径, 会自动重写URI, 追加/, 并返回一个HTTP重定向.

内容处理器的内容会被过滤器过滤. 过滤器和location绑定, 一个location可能会有多个过滤器. 过滤器会修改处理器输出的内容. 过滤器的执行顺序在编译的时候就已经固定下来了. 对于自带的过滤器, 顺序是固定的; 而第三方的过滤器, 可以在编译的时候指定顺序. 就目前的nginx实现来说, 过滤器只能对输出内容进行修改, 不支持对输入内容进行写入和修改. 输入过滤器在将来的nginx版本中会支持.

过滤器使下面所说的这种”流水线”的设计模式成为可能: 过滤器被调用执行, 并调用了下一个过滤器, 直到整个过滤链最后一环执行完成. 之后, nginx结束了请求的处理. 过滤器不需要等前一个过滤器完成. 一旦上游过滤器开始有内容, 下游过滤器就可以开始工作 (很像UNIX的管道机制). 所以, 上游服务的输出尚未完全被接收的时候, 就可以向客户端的返回内容.

过滤器分为头部过滤器和内容过滤器, nginx将响应的头部及内容分别发给不同的过滤器.

头部过滤器有以下三大步:

  1. 决定是否处理这个响应
  2. 处理这个响应
  3. 调用下一个过滤器

内容过滤器修改返回的内容. 例如:

过滤链结束后, 响应被交给写者处理. 和写者相关联的, 还有其他特定用途的过滤器, 如copy过滤器, postpone过滤器. copy过滤器负责将可能被放入代理临时目录的内容写入内存缓存. postpone过滤器和子请求相关.

子请求对于请求/响应处理非常重要, 也是nginx最强大的地方. 通过子请求, nginx可以返回和URL原始路径不同的内容. 有些Web框架称之为内部重定向. nginx不限于此, 处理过滤多个子请求并合并为一个响应, 子请求是可以嵌套并有层级关系的. 子请求可以有自己的子子请求, 如此下去. 子请求可以映射到磁盘上的文件, 其它处理器, 或者上游服务. 举例来说, 服务器端引入(SSI)模块用过滤器解析返回的内容, 并将include字段替换为其所指定的URL内容. 或者, 可以将获取的文件内容当作作URL, 并将获取的新文件的内容追加到这个URL中.

上游服务模块以及负载均衡器模块也值得介绍一下.

上游服务可以被理解为反向代理的内容处理器 ((proxy_pass 处理器). 上游服务模块主要是讲请求发送给上游服务, 获取响应内容. 没有输出过滤器的调用. 上游服务模块具体所做的事情就是, 在上游服务有数据可读时触发回调. 我们有实现了如下功能的各种回调:

负载均衡器模块和proxy_pass处理器相关, 在多个上游服务可用时, 决定选在哪一个. 负载均衡器注册了一个开关配置, 提供了额外的上游服务初始化函数 (解析上有服务域名, 等), 初始化连接的结构, 决定路由请求地址, 以及跟新状态信息. 当前nginx支持两种负载均衡策略: 轮流服务, 以及基于IP的hash.

负载均衡机制包括了一系列检测失效上游服务, 重新路由到余下上游服务的算法. 当然, 我们也计划对此做更多的改进. 总的来说, 我们计划对负载均衡这块做更多的开发, 在后续的版本中, 请求分发及上游服务健康检查将得到极大地优化.

另外一些有趣的模块, 为我们提供了额外的配置变量. 尽管变量会在不同的模块间被创建并更新, 有两个模块专门处理变量: geo以及map. geo模块目的是根据客户端的IP做跟踪. 这个模块可以基于客户IP, 创建任意的变量. map模块, 允许通过其他变量来创建变量, 从而提供了域名以及其他运行时变量的灵活映射能力. 这些模块可以被称之为变量模块.

在过去, nginx的内存分配机制受到Apache的影响: 每个链接所需的内存是动态分配引用, 用于储存修改请求和响应的头部及内容, 并在连接解除的时候释放. 要注意一点, nginx尽可能的避免内存拷贝, 绝大部分数据通过指针传递, 而不是memcpy.

深入一点说, 当一个模块返回响应的时候, 获取的内容被放在一个内存缓冲区里面, 并添加到缓冲链中. 之后的处理和这个缓冲链打交道. 由于会根据模块类型有不同的处理场景, 缓冲链会非常复杂. 例如, 当执行一个内容过滤模块的时候, 很难去精确控制缓冲区. 这种模块一次只能处理一个缓冲(链), 并且需要决定是否重写, 替换, 还是追加一个缓冲区. 更复杂的是, 有时, 一个模块可能会同时收到多个缓冲区, 导致当前工作的缓冲链内容不完整. 在这种情况下, nginx仅提供了一个操作缓冲链的底层API, 所以开发者在实现第三方模块的时候, 需要对这块儿特别熟悉.

上述的内存缓冲区, 在一个连接的整个周期内都存在. 因此, 对于长连接来说, 会导致一些额外的内存使用. 另外, 对于一个空闲的keepalive连接, nginx仅仅分配了550字节内存. 在将来的nginx版本里, 一个可能的优化是, 共享并重用长连接所占用的内存.

nginx内存池分配器负责内存的分配. 共享内存用来保存锁, 缓存元数据, SSl会话缓存, 以及关于带宽的管理策略信息. 共享内存由一个对象缓存分配器(slab allocator)负责管理. 为了保证共享内存使用的线程安全性, nginx提供了一系列锁机制, 如互斥锁, 信号量. 为了管理复杂的数据结构, nginx也提供了红黑树的实现. 红黑树被用在了缓存元数据跟踪, 非正则规则的location定义记录, 以及其他一些地方.

不幸的是, 上面这些从没有被很好地记录下来, 从而导致第三方模块的开发非常复杂. 虽然已经有一些非常好的关于nginx内部的文档 (比如说, Evan Miller编写的), 但是写这些文档需要浩大的反向工程工作, 导致ngxin模块开发对很多人来说, 仍然是很陌生.

尽管有开发的种种困难, 我们仍然看到许多优秀的第三方扩展模块. 如嵌入Lua脚本的模块, 额外的负载均衡模块, 完整的WebDAV支持, 高级缓存控制, 以及其他各种模块, 本作者非常鼓励这些模块的开发, 并在将来会做到很好的支持.

HOME