用lua扩展你的Nginx

一. 概述

Nginx是一个高性能。支持高并发的,轻量级的webserver。眼下,Apache依旧webserver中的老大,可是在全球前1000大的webserver中,Nginx的份额为22.4%。Nginx採用模块化的架构,官方版本号的Nginx中大部分功能都是通过模块方式提供的,比方Http模块、Mail模块等。通过开发模块扩展Nginx,能够将Nginx打造成一个全能的应用server,这样能够将一些功能在前端Nginx反向代理层解决,比方登录校验、js合并、甚至数据库訪问等等。     可是,Nginx模块需要用C开发,并且必须符合一系列复杂的规则。最重要的用C开发模块必需要熟悉Nginx的源码。使得开发人员对其望而生畏。淘宝的agentzh和chaoslawful开发的ngx_lua模块通过将lua解释器集成进Nginx。能够採用lua脚本实现业务逻辑,因为lua的紧凑、高速以及内建协程,所以在保证高并发服务能力的同一时候极大地减少了业务逻辑实现成本。

    本文向大家介绍ngx_lua,以及我在使用它开发项目的过程中遇到的一些问题。
 

二. 准备

首先,介绍一下Nginx的一些特性,便于后文介绍ngx_lua的相关特性。

Nginx进程模型

Nginx採用多进程模型,单Master—多Worker,由Master处理外部信号、配置文件的读取及Worker的初始化。Worker进程採用单线程、非堵塞的事件模型(Event Loop,事件循环)来实现port的监听及client请求的处理和响应,同一时候Worker还要处理来自Master的信号。

因为Worker使用单线程处理各种事件。所以一定要保证主循环是非堵塞的,否则会大大减少Worker的响应能力。

Nginx处理Http请求的过程

表面上看,当Nginx处理一个来自client的请求时,先依据请求头的host、ip和port来确定由哪个server处理,确定了server之后,再依据请求的uri找到相应的location。这个请求就由这个location处理。

实际Nginx将一个请求的处理划分为若干个不同阶段(phase)。这些阶段依照前后顺序依次运行。也就是说NGX_HTTP_POST_READ_PHASE在第一个,NGX_HTTP_LOG_PHASE在最后一个。

<span style="font-size:10px;">NGX_HTTP_POST_READ_PHASE,     //0读取请求phase
NGX_HTTP_SERVER_REWRITE_PHASE,//1这个阶段主要是处理全局的(server block)的rewrite
NGX_HTTP_FIND_CONFIG_PHASE,   //2这个阶段主要是通过uri来查找相应的location,然后依据loc_conf设置r的相应变量
NGX_HTTP_REWRITE_PHASE,       //3这个主要处理location的rewrite
NGX_HTTP_POST_REWRITE_PHASE,  //4postrewrite,这个主要是进行一些校验以及收尾工作。以便于交给后面的模块。

NGX_HTTP_PREACCESS_PHASE, //5比方流控这样的类型的access就放在这个phase,也就是说它主要是进行一些比較粗粒度的access。

NGX_HTTP_ACCESS_PHASE, //6这个比方存取控制,权限验证就放在这个phase,一般来说处理动作是交给以下的模块做的.这个主要是做一些细粒度的access NGX_HTTP_POST_ACCESS_PHASE, //7一般来说当上面的access模块得到access_code之后就会由这个模块依据access_code来进行操作 NGX_HTTP_TRY_FILES_PHASE, //8try_file模块,就是相应配置文件里的try_files指令。可接收多个路径作为參数。当前一个路径的资源无法找到,则自己主动查找下一个路径 NGX_HTTP_CONTENT_PHASE, //9内容处理模块 NGX_HTTP_LOG_PHASE //10log模块

每一个阶段上能够注冊handler。处理请求就是执行每一个阶段上注冊的handler。Nginx模块提供的配置指令仅仅会一般仅仅会注冊并执行在当中的某一个处理阶段。

比方,set指令属于rewrite模块的,执行在rewrite阶段,deny和allow执行在access阶段。


子请求(subrequest)

事实上在Nginx 世界里有两种类型的“请求”。一种叫做“主请求”(main request),而还有一种则叫做“子请求”(subrequest)。 所谓“主请求”。就是由 HTTP client从 Nginx 外部发起的请求。比方。从浏览器訪问Nginx就是一个“主请求”。 而“子请求”则是由 Nginx 正在处理的请求在 Nginx 内部发起的一种级联请求。“子请求”在外观上非常像 HTTP 请求,但实现上却和 HTTP 协议乃至网络通信一点儿关系都没有。它是 Nginx 内部的一种抽象调用,目的是为了方便用户把“主请求”的任务分解为多个较小粒度的“内部请求”,并发或串行地訪问多个 location 接口。然后由这些 location 接口通力协作,共同完毕整个“主请求”。当然。“子请求”的概念是相对的,不论什么一个“子请求”也能够再发起很多其它的“子子请求”。甚至能够玩递归调用(即自己调用自己)。

当一个请求发起一个“子请求”的时候,依照 Nginx 的术语,习惯把前者称为后者的“父请求”(parent request)。

location /main {
    echo_location /foo;     # echo_location发送子请求到指定的location
    echo_location /bar;
}
location /foo {
    echo foo;
}
location /bar {
    echo bar;
}

输出:

$ curl location/main
$ foo   03.  bar

这里,main location就是发送2个子请求,分别到foo和bar。这就类似一种函数调用。

“子请求”方式的通信是在同一个虚拟主机内部进行的。所以 Nginx 核心在实现“子请求”的时候,就仅仅调用了若干个 C 函数,全然不涉及不论什么网络或者 UNIX 套接字(socket)通信。我们由此能够看出“子请求”的运行效率是极高的。

协程(Coroutine)

协程类似一种多线程,与多线程的差别有: 

1. 协程并不是os线程,所以创建、切换开销比线程相对要小。 

2. 协程与线程一样有自己的栈、局部变量等,可是协程的栈是在用户进程空间模拟的,所以创建、切换开销非常小。

3. 多线程程序是多个线程并发运行。也就是说在一瞬间有多个控制流在运行。而协程强调的是一种多个协程间协作的关系,仅仅有当一个协程主动放弃运行权,还有一个协程才干获得运行权,所以在某一瞬间,多个协程间仅仅有一个在运行。 

4. 因为多个协程时仅仅有一个在执行,所以对于临界区的訪问不须要加锁。而多线程的情况则必须加锁。

5. 多线程程序因为有多个控制流。所以程序的行为不可控,而多个协程的运行是由开发人员定义的所以是可控的。 

Nginx的每一个Worker进程都是在epoll或kqueue这种事件模型之上,封装成协程,每一个请求都有一个协程进行处理。这正好与Lua内建协程的模型是一致的,所以即使ngx_lua须要运行Lua,相对C有一定的开销,但依旧能保证高并发能力。

三. ngx_lua

原理
ngx_lua将Lua嵌入Nginx,能够让Nginx运行Lua脚本,而且高并发、非堵塞的处理各种请求。Lua内建协程。这样就能够非常好的将异步回调转换成顺序调用的形式。ngx_lua在Lua中进行的IO操作都会托付给Nginx的事件模型。从而实现非堵塞调用。开发人员能够採用串行的方式编敲代码,ngx_lua会自己主动的在进行堵塞的IO操作时中断。保存上下文;然后将IO操作托付给Nginx事件处理机制。在IO操作完毕后,ngx_lua会恢复上下文,程序继续运行,这些操作都是对用户程序透明的。

每一个NginxWorker进程持有一个Lua解释器或者LuaJIT实例,被这个Worker处理的全部请求共享这个实例。

每一个请求的Context会被Lua轻量级的协程切割,从而保证各个请求是独立的。 ngx_lua採用“one-coroutine-per-request”的处理模型。对于每一个用户请求,ngx_lua会唤醒一个协程用于执行用户代码处理请求,当请求处理完毕这个协程会被销毁。

每一个协程都有一个独立的全局环境(变量空间),继承于全局共享的、仅仅读的“comman data”。所以。被用户代码注入全局空间的不论什么变量都不会影响其它请求的处理。而且这些变量在请求处理完毕后会被释放,这样就保证全部的用户代码都执行在一个“sandbox”(沙箱),这个沙箱与请求具有同样的生命周期。 得益于Lua协程的支持。ngx_lua在处理10000个并发请求时仅仅须要非常少的内存。依据測试,ngx_lua处理每一个请求仅仅须要2KB的内存,假设使用LuaJIT则会更少。所以ngx_lua非常适合用于实现可扩展的、高并发的服务。


典型应用

Hello Lua!

# nginx.conf
worker_processes 4;

events {
     worker_connections 1024;
}
http {

    server {
        listen 80;
        server_name localhost;

        location=/lua {
            content_by_lua ‘
                ngx.say("Hello, Lua!")
            ';
        }
    }
}

输出:
$ curl 'localhost/lua'
Hello,Lua。

这样就实现了一个非常easy的ngx_lua应用。假设这么简单的模块要是用C来开发的话,代码量预计得有100行左右。从这就能够看出ngx_lua的开发效率。

Benchmark
通过和nginx訪问静态文件还有nodejs比較,来看一下ngx_lua提供的高并发能力。 返回的内容都是”Hello World!”,151bytes 通过.ab -n 60000   取10次平均
从图表中能够看到,在各种并发条件下ngx_lua的rps都是最高的。而且基本维持在10000rps左右,nginx读取静态文件由于会有磁盘io所以性能略差一些,而nodejs是相对最差的。通过这个简单的測试,能够看出ngx_lua的高并发能力。 ngx_lua的开发人员也做过一个測试对照nginx+fpm+php和nodejs,他得出的结果是ngx_lua能够达到28000rps。而nodejs有10000多一点。php则最差仅仅有6000。可能是有些配置我没有配好导致ngx_lua rps没那么高。

ngx_lua安装

ngx_lua安装能够通过下载模块源代码,编译Nginx。可是推荐採用openresty。Openresty就是一个打包程序,包括大量的第三方Nginx模块,比方HttpLuaModule,HttpRedis2Module,HttpEchoModule等。省去下载模块。而且安装很方便。

ngx_openresty bundle: openresty ./configure --with-luajit&& make && make install 默认Openresty中ngx_lua模块採用的是标准的Lua5.1解释器。通过--with-luajit使用LuaJIT。
ngx_lua的使用方法
ngx_lua模块提供了配置指令和Nginx API。

配置指令:在Nginx中使用,和set指令和pass_proxy指令用法一样。每一个指令都有使用的context。        Nginx API:用于在Lua脚本中訪问Nginx变量,调用Nginx提供的函数。 以下举例说明常见的指令和API。



配置指令

set_by_lua和set_by_lua_file

和set指令一样用于设置Nginx变量而且在rewrite阶段运行,仅仅只是这个变量是由lua脚本计算并返回的。
语法:set_by_lua$res <lua-script-str> [$arg1 $arg2 ...]

配置:

location =/adder {
    set_by_lua $res"
            local a = tonumber(ngx.arg[1])
                local b = tonumber(ngx.arg[2])
                return a + b"$arg_a$arg_b;

        echo$res;
}

输出:
$ curl 'localhost/adder?a=25&b=75'
$ 100

set_by_lua_file运行Nginx外部的lua脚本,能够避免在配置文件里使用大量的转义。

配置:

location =/fib {
        set_by_lua_file $res "conf/adder.lua" $arg_n;

        echo $res;
}</span>

adder.lua:

local a=tonumber(ngx.arg[1])
local b=tonumber(ngx.arg[2])
return a + b


输出:
$ curl 'localhost/adder?a=25&b=75
$ 100
 

access_by_lua和access_by_lua_file

执行在access阶段。用于訪问控制。

Nginx原生的allow和deny是基于ip的。通过access_by_lua能完毕复杂的訪问控制。比方。訪问数据库进行username、password验证等。

配置:

location /auth {
    access_by_lua '
        if ngx.var.arg_user == "ntes" then
            return
        else
            Ngx.exit(ngx.HTTP_FORBIDDEN)
        end
    ';
    echo'welcome ntes';
}

输出:
$ curl 'localhost/auth?user=sohu'
$ Welcome ntes

$ curl 'localhost/auth?user=ntes'
$ <html>
<head><title>403 Forbidden</title></heda>
<body bgcolor="white">
<center><h1>403 Forbidden</h1></center>
<hr><center>ngx_openresty/1.0.10.48</center>
</body>
</html>
 

rewrite_by_lua和rewrite_by_lua_file

实现url重写。在rewrite阶段运行。

配置:

location =/foo {
        rewrite_by_lua 'ngx.exec("/bar")';
    echo'in foo';
}

location =/bar {
        echo'in bar';
}

输出:
$ curl 'localhost/lua'
$ Hello, Lua!

content_by_lua和content_by_lua_file
 

Contenthandler在content阶段运行,生成http响应。因为content阶段仅仅能有一个handler。所以在与echo模块使用时,不能同一时候生效,我測试的结果是content_by_lua会覆盖echo。这和之前的hello world的样例是类似的。

配置(直接响应):

location =/lua {
        content_by_lua 'ngx.say("Hello, Lua!")';
}


输出:
$ curl 'localhost/lua'
$ Hello, Lua!

配置(在Lua中訪问Nginx变量):

location =/hello {
        content_by_lua '
            local who = ngx.var.arg_who
            ngx.say("Hello, ", who, "!")
        ';
}


输出:
$ curl 'localhost/hello?who=world
$ Hello, world!

Nginx API
Nginx API被封装ngx和ndk两个package中。

比方ngx.var.NGX_VAR_NAME能够訪问Nginx变量。这里着重介绍一下ngx.location.capture和ngx.location.capture_multi。

ngx.location.capture
语法:res= ngx.location.capture(uri, options?)     用于发出一个同步的,非堵塞的Nginxsubrequest(子请求)。

能够通过Nginx subrequest向其他location发出非堵塞的内部请求。这些location能够是配置用于读取目录的,也能够是其他的C模块,比方ngx_proxy, ngx_fastcgi, ngx_memc, ngx_postgres, ngx_drizzle甚至是ngx_lua自己。     Subrequest仅仅是模拟Http接口,并没有额外的Http或者Tcp传输开销,它在C层次上执行,很高效。Subrequest不同于Http 301/302重定向,以及内部重定向(通过ngx.redirection)。

配置:

location =/other {
    ehco 'Hello, world!';
}

# Lua非堵塞IO
location =/lua {
    content_by_lua '
        local res = ngx.location.capture("/other")
        if res.status == 200 then
            ngx.print(res.body)
        end
    ';
}


输出:
$ curl  'http://localhost/lua'
$ Hello, world!

实际上,location能够被外部的Http请求调用,也能够被内部的子请求调用。每一个location相当于一个函数,而发送子请求就类似于函数调用。并且这样的调用是非堵塞的,这就构造了一个很强大的变成模型,后面我们会看到怎样通过location和后端的memcached、redis进行非堵塞通信。
ngx.location.capture_multi

语法:res1,res2, ... = ngx.location.capture_multi({ {uri, options?}, {uri, options?}, ...})     与ngx.location.capture功能一样,能够并行的、非堵塞的发出多个子请求。这种方法在全部子请求处理完毕后返回。而且整个方法的执行时间取决于执行时间最长的子请求,并非全部子请求的执行时间之和。

配置:

# 同一时候发送多个子请求(subrequest)
location =/moon {
    ehco 'moon';
}
location =/earth {
    ehco 'earth';
}

location =/lua {
    content_by_lua '
        local res1,res2 = ngx.location.capture_multi({ {"/moon"}, {"earth"} })
        if res1.status == 200 then
            ngx.print(res1.body)
        end
        ngx.print(",")
        if res2.status == 200 then
            ngx.print(res2.body)
        end
    ';
}



输出:
$ curl  'http://localhost/lua'
$ moon,earth

注意
在Lua代码中的网络IO操作仅仅能通过Nginx Lua API完毕。假设通过标准Lua API会导致Nginx的事件循环被堵塞,这样性能会急剧下降。     在进行数据量相当小的磁盘IO时能够採用标准Lua io库,可是当读写大文件时这样是不行的,由于会堵塞整个NginxWorker进程。为了获得更大的性能。强烈建议将全部的网络IO和磁盘IO托付给Nginx子请求完毕(通过ngx.location.capture)。     以下通过訪问/html/index.html这个文件。来測试将磁盘IO托付给Nginx和通过Lua io直接訪问的效率。     通过ngx.location.capture托付磁盘IO:
 

配置:

location / {
    internal;
    root html;
}

location /capture {
    content_by_lua '
        res = ngx.location.capture("/")
        echo res.body
    ';
}

通过标准lua io訪问磁盘文件:

配置:

location /luaio{
    content_by_lua '
        local io = require("io")
        local chunk_SIZE = 4096
        local f = assert(io.open("html/index.html","r"))
        while true do
            local chunk = f:read(chunk)
            if not chunk then
                break
            end
            ngx.print(chunk)
            ngx.flush(true)
        end
        f:close()
    ';
}


这里通过ab去压,在各种并发条件下,分别返回151bytes、151000bytes的数据,取10次平均,得到两种方式的rps。     静态文件:151bytes
1000 3000 5000 7000 10000  capture  11067 8880 8873 8952 9023  Lua io     11379 9724 8938 9705 9561

静态文件:151000bytes。在10000并发下内存占用情况太严重。測不出结果        这样的情况下,文件较小,通过Nginx訪问静态文件须要额外的系统调用,性能略逊于ngx_lua。


1000 3000 5000 7000    10000  capture    3338 3435 3178 3043         /  Lua io      3174 3094 3081 2916         /

在大文件的情况。capture就要略好于ngx_lua。      这里没有对Nginx读取静态文件进行优化配置。仅仅是採用了sendfile。

假设优化一下。可能nginx读取静态文件的性能会更好一些,这个眼下还不熟悉。

所以,在Lua中进行各种IO时。都要通过ngx.location.capture发送子请求托付给Nginx事件模型,这样能够保证IO是非堵塞的。


 

四. 进阶

在之前的文章中。已经介绍了ngx_lua的一些基本介绍,这篇文章主要着重讨论一下怎样通过ngx_lua同后端的memcached、redis进行非堵塞通信。

Memcached

在Nginx中訪问Memcached须要模块的支持,这里选用HttpMemcModule,这个模块能够与后端的Memcached进行非堵塞的通信。我们知道官方提供了Memcached,这个模块仅仅支持get操作。而Memc支持大部分Memcached的命令。 Memc模块採用入口变量作为參数进行传递。全部以$memc_为前缀的变量都是Memc的入口变量。

memc_pass指向后端的Memcached Server。

配置:

#使用HttpMemcModule
location =/memc {
    set $memc_cmd $arg_cmd;
    set $memc_key $arg_key;
    set $memc_value $arg_val;
    set $memc_exptime $arg_exptime;

    memc_pass '127.0.0.1:11211';
}

输出:
$ curl  'http://localhost/memc?cmd=set&key=foo&val=Hello'
$ STORED
$ curl  'http://localhost/memc?

cmd=get&key=foo'
$ Hello

这就实现了memcached的訪问。以下看一下怎样在lua中訪问memcached。



配置:

#在Lua中訪问Memcached
location =/memc {
    internal;   #仅仅能内部訪问
    set $memc_cmd get;
    set $memc_key $arg_key;
    memc_pass '127.0.0.1:11211';
}
location =/lua_memc {
    content_by_lua '
        local res = ngx.location.capture("/memc", {
            args = { key = ngx.var.arg_key }
        })
        if res.status == 200 then
            ngx.say(res.body)
        end
    ';
}


输出:
$ curl  'http://localhost/lua_memc?

key=foo'
$ Hello

通过lua訪问memcached。主要是通过子请求採用一种类似函数调用的方式实现。

首先。定义了一个memc location用于通过后端memcached通信,就相当于memcached storage。

因为整个Memc模块时非堵塞的。ngx.location.capture也是非堵塞的,所以整个操作非堵塞。

Redis

訪问redis须要HttpRedis2Module的支持,它也能够同redis进行非堵塞通行。只是,redis2的响应是redis的原生响应,所以在lua中使用时,须要解析这个响应。能够採用LuaRedisModule,这个模块能够构建redis的原生请求。并解析redis的原生响应。

配置:

#在Lua中訪问Redis
location =/redis {
    internal;   #仅仅能内部訪问
    redis2_query get $arg_key;
    redis2_pass '127.0.0.1:6379';
}
location =/lua_redis {#须要LuaRedisParser
    content_by_lua '
        local parser = require("redis.parser")
        local res = ngx.location.capture("/redis", {
            args = { key = ngx.var.arg_key }
        })
        if res.status == 200 then
            reply = parser.parse_reply(res.body)
            ngx.say(reply)
        end
    ';
}


输出:
$ curl  'http://localhost/lua_redis?key=foo'
$ Hello

和訪问memcached类似。须要提供一个redis storage专门用于查询redis,然后通过子请求去调用redis。

Redis Pipeline
在实际訪问redis时,有可能须要同一时候查询多个key的情况。

我们能够採用ngx.location.capture_multi通过发送多个子请求给redis storage,然后在解析响应内容。

可是,这会有个限制,Nginx内核规定一次能够发起的子请求的个数不能超过50个。所以在key个数多于50时,这样的方案不再适用。
幸好redis提供pipeline机制。能够在一次连接中运行多个命令,这样能够降低多次运行命令的往返时延。

client在通过pipeline发送多个命令后。redis顺序接收这些命令并运行,然后依照顺序把命令的结果输出出去。在lua中使用pipeline须要用到redis2模块的redis2_raw_queries进行redis的原生请求查询。
 

配置:

#在Lua中訪问Redis
location =/redis {
    internal;   #仅仅能内部訪问
    redis2_raw_queries $args$echo_request_body;
    redis2_pass '127.0.0.1:6379';
}

location =/pipeline {
    content_by_lua 'conf/pipeline.lua';
}

pipeline.lua

-- conf/pipeline.lua file
local parser=require(‘redis.parser’)
local reqs={
    {‘get’, ‘one’}, {‘get’, ‘two’}
}
-- 构造原生的redis查询。get one\r\nget two\r\n
local raw_reqs={}
for i, req in ipairs(reqs)do
      table.insert(raw_reqs, parser.build_query(req))
end
local res=ngx.location.capture(‘/redis?

’..#reqs, {body=table.concat(raw_reqs, ‘’)}) if res.status and res.body then -- 解析redis的原生响应 local replies=parser.parse_replies(res.body, #reqs) for i, reply in ipairs(replies)do ngx.say(reply[1]) end end


输出:
$ curl  'http://localhost/pipeline'
$ first
  second
 

Connection Pool

前面訪问redis和memcached的样例中。在每次处理一个请求时。都会和后端的server建立连接。然后在请求处理完之后这个连接就会被释放。

这个过程中,会有3次握手、timewait等一些开销。这对于高并发的应用是不可容忍的。这里引入connection pool来消除这个开销。 连接池须要HttpUpstreamKeepaliveModule模块的支持。



配置:

http {
    # 须要HttpUpstreamKeepaliveModule
    upstream redis_pool {
        server 127.0.0.1:6379;
        # 能够容纳1024个连接的连接池
        keepalive 1024 single;
    }

    server {
        location=/redis {
            …
            redis2_pass redis_pool;
        }
    }
}


这个模块提供keepalive指令。它的context是upstream。我们知道upstream在使用Nginx做反向代理时使用。实际upstream是指“上游”。这个“上游”能够是redis、memcached或是mysql等一些server。upstream能够定义一个虚拟server集群,而且这些后端的server能够享受负载均衡。keepalive 1024就是定义连接池的大小,当连接数超过这个大小后,兴许的连接自己主动退化为短连接。连接池的使用非常easy,直接替换掉原来的ip和port号就可以。      有人以前測过,在没有使用连接池的情况下,訪问memcached(使用之前的Memc模块),rps为20000。在使用连接池之后,rps一路飙到140000。在实际情况下。这么大的提升可能达不到,可是基本上100-200%的提高还是能够的。
 

小结

这里对memcached、redis的訪问做个小结。

1. Nginx提供了强大的编程模型。location相当于函数,子请求相当于函数调用,而且location还能够向自己发送子请求,这样构成一个递归的模型,所以採用这样的模型实现复杂的业务逻辑。 2. Nginx的IO操作必须是非堵塞的,假设Nginx在那阻着,则会大大减少Nginx的性能。所以在Lua中必须通过ngx.location.capture发出子请求将这些IO操作托付给Nginx的事件模型。

3. 在须要使用tcp连接时,尽量使用连接池。

这样能够消除大量的建立、释放连接的开销。

猜你喜欢

转载自blog.csdn.net/belalds/article/details/84633141
今日推荐