用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連線時,儘量使用連線池。
這樣能夠消除大量的建立、釋放連線的開銷。