CSDI2018廣州關於《Nginx》的分享(附文字速錄與PPT)

NO IMAGE

應百林哲笑含的邀請,於2018.6.9號至7.1號前往廣州白雲國際會議中心參加《CSDI Summit 中國軟體研發管理行業技術峰會》。會上認識了很多網際網路一線老師是最大的收穫:

本次我分享的主題是《兼顧靈活與效能的nginx》:

意外的驚喜是CSDI的講師證書非常精美:

最後附上本次演講的PPT內容:

兼顧靈活與效能的nginx

以下為文字速錄內容:

大家好,我是杭州市智鏈達資料有限公司的聯合創始人和CTO,為什麼又要介紹一下?因為我們公司是一個網際網路服務企業,但是我們面向的客戶是建築企業,所以在座的各位都不是我的潛在客戶,所以接下來不會有任何介紹關於我們產品的推廣和介紹:-)。從我的這個分享的標題中可以看到,這裡其實有兩個關鍵詞,一個是效能,一個是靈活,我們接下來討論這兩點中nginx是怎麼做到的。當前nginx已經是所有的網際網路企業的一個標配底層元件,所以能分享這樣一個大眾化的廣為使用的工具,我個人感到很榮幸。不管是小流量還是大流量場景使用了nginx後都可以有一個立竿見影的效果。

那麼本來的話nginx介紹這部分可以沒有,但是我相信在座的各位應該在生產環境中時實際操作過nginx的同學應該不是很多吧?能不能請在生產環境中直接使用過nginx,或者你帶的團隊負責nginx的同學,能不能舉一下手?我看一下還是有一半以上的同學沒有操作過nginx的,所以我會用五分鐘的時間先做個簡單的介紹。

首先我們肯定是先看它的使用場景,那麼場景的話呢先從這個最右邊的這個靜態資源來看,我們現在不管是開發一個web頁面或者是開發一個APP webview,都會去拉取大量的CSS、JS、小圖片等資源,這些資源的空間佔用量其實很大,也很難放在記憶體中,所以只能放在磁碟上。nginx非常擅長把磁碟中的內容以http協議的方式返回給客戶端,所以這是nginx第一個場景。第二個場景中,如果我們的應用服務是用python寫的,可能也就幾百QPS,JAVA寫的服務可能有上千QPS,如果是GOLANG寫的可能有上萬QPS,但是nginx擁有上百萬QPS的系統能力,所以如果我們需要儘量提高我們的這個系統容量,那麼需要把很多的應用服務組成一個叢集來對使用者提供服務,在早期的時候,我們可能會用DNS等手段做負載均衡,而現在呢都是採用nginx做反向代理,因為它卓越的單機效能很適合該場景。那麼在反向代理使用場景中就會有另外一個問題,負載均衡,我們經常會需要擴容,宕機容災時這個負載均衡可以發揮很好的作用。那麼有了反向代理後又會引出另外一個問題就是快取。

因為其實在網際網路行業中,只要我們想提升使用者的體驗,基本上都是在快取上下功夫,而快取基本上你是放在離使用者越近的地方效果越好。比如說你放在手機APP的儲存上,或者放在瀏覽器的storage裡,這樣的使用者體驗最好!或者說再差一點到網路中了,那麼放在cdn效果也是很好,但如果請求到了我們企業內網中,這個時候,往往離使用者最近效果最好,比如說像mysql資料庫它雖然專注於只做一件事,以致於他的這個快取做的是非常厲害,但是他的能力再強也沒有用,因為資料庫前面會有一個業務應用服務,應用服務強調的是快速迭代,它強調的是對程式設計師友好以提升開發效率,所以呢你想它效能好是不可能的。所以這個時候呢我們在這個nginx上做快取,因為反向代理協議就很簡單,做快取也很方便,我們最後也可以拿到好的結果。

第三個場景呢就是中間這個廣場。有一些高頻的介面呼叫,比如說像使用者鑑權,還有像前天有一位阿里巴巴國際部的老師說他們的流量導流等應用,這些東西都需要這個nginx要發揮自己的特長,然後不要跟慢吞吞的應用服務扯上關係。就像我剛剛說的,其實資料庫例如mysql他的能力是很強的,那麼如果這些業務可以直接在nginx上實現,那麼其實我們就可以提供一個API。那麼API服務實現上有幾個難點,第一個呢以前的nginx往往是通過每個第三方模組自行定義它自己獨特的配置格式,以此實現複雜的業務功能,但這種模式是會有很多問題,因為你是獨特的不是通用的,而且且學習成本很高,擴充套件性也不好。所以呢以通用程式語言實現是一個好思路,官方還搞了一個javascript版本,而openresty搞了一個lua版本,那麼因為引入了程式語言,那麼你可以很方便的呼叫工具SDK,所以做API服務就有了可行性,這是最主要的應用場景。

OK那麼再看一下nginx的程序架構,我相信在坐的各位都知道nginx的master/worker程序模型。但是我不知道大家有沒有想到為什麼是一個多程序的架構?可以橫向對比一下,比如說nodejs,比如說redis,他們都有一個明顯的問題:就是沒有辦法使用多核。那麼nginx呢,因為他在定義核心目標時,他想做的事就是在我們企業內網最外層,獲取這臺伺服器的極限能力以實現上述功能,所以nginx希望能夠有效的耗盡cpu、記憶體等資源。那麼master程序它其實非常輕量級,他只是去監控,只是去管理,雖然master也提供了鉤子方法供第三方模組介入,但其實像很多第三方模組並不會在master裡面做文章,因為只要你有業務在master裡就引入了不確定性,master是不能掛,掛了以後你沒有辦法管理實際工作的worker程序。如果我們做快取的時候,其實又多了兩個程序cache manager和cache loader,這兩個程序也是不處理實際請求的。
 
再看一下編譯方式。nginx通常是採用把官方的原始碼和這個第三方模組的原始碼放在一起編譯出一個二進位制可執行檔案,那怎麼編譯?官方他會提供一個叫bash指令碼叫configure,而Tengine或者openresty也會提供自己的configure指令碼,它負責把這些原始碼以有序的方式整合在一起(下面會說到)。那麼編譯出來這個二進位制檔案之後呢我們就可以執行了。近期的版本nginx向大家提供了動態模組的功能,就像windows作業系統中的dll,或者linux作業系統中的so,它們又提供了一層封裝,降低了耦合度。就像圖中所示,這時候就會把這個動態庫開啟,把其程式碼載入至nginx的程序地址空間中,那麼有了這個動態模組好處在哪裡?如果說我只修改了一個模組,並未對其他模組發生變動,那麼就不用重新編譯出新的可執行檔案了,我只要去換一下這個動態庫就可以。
 
灰度釋出可能大家都比較清楚,這是nginx的基本功能了,我們簡單過一下。比如說上面這張圖,master程序上拉起了四個綠色的worker程序,這四個worker程序用的是老配置檔案裡的配置,然後呢我們修改了nginx.conf配置檔案,呼叫了-s reload命令後,master程序會起四個黃色的worker程序,這四個黃色的worker程序使用了新的配置檔案裡的內容。如果我們自己去寫一個應用程式實現配置修改時,不知道大家會怎麼寫?那我可能會想,把應用程序kill掉,再重新拉起讀取配置不就完了嗎?那是nginx不能這麼做,因為它上面真的跑了幾十萬個tcp連線,所以如果他被kill掉,實際上幾十萬的客戶端都會收到RST復位包,體驗是非常差的。所以nginx要採用這麼一個複雜的形式,就是綠色的worker程序還在處理老的連線與請求,而黃色的worker程序就只處理新建立的連線與請求,等到綠色的老worker程序處理完老連線上的請求後,我們再停掉worker程序就沒有問題了。但是說起來很簡單,其實還是有許多問題要處理,比如說如果是HTTP這樣的請求那還比較好,即使是keepalive連線也OK,但如果是websocket協議或者其他TCP協議怎麼辦?nginx不解析協議內容,不知道什麼時候可以準確的判斷處理完一個請求,這樣粗暴的方式就可能直接斷使用者連線。worker_shutdown_timeout這個配置就是做這個事的。
 
那我們現在進入第二部分談談nginx的模組化。模組化決定了nginx的能力,比如說TCP這個協議,它是上世紀70年代就發明了,我們中間可能有各種各樣的改進,比如說擁塞控制等等,但是到了現在還是非常好用。其實nginx也一樣,我們掌握了它的模組化思想,就理解了它的底層能力。nginx的模組我不知道如果讓你去設計,你會怎麼思考?看下這個圖,首先它會有核心模組core,這下面的四個模組是框架與工具模組,比如說這個errlog是記錄錯誤日誌的。這上面這四個核心模組,每一個都定義了一類新模組。這個就比較關鍵,那麼像這個mail等模組並不重要,我們重點還是看http模組。所有的http模組他們其實就構成了一張陣列列表,這個列表裡面的第一個元素也是第一個http模組都有個關鍵字_core_,包括mail、stream模組都是一樣的。那麼每類模組列表裡的第一個core模組存在的意義在哪裡呢?它們負責處理本類模組裡的共性規則。第一個共性的東西就是協議,比如http協議,一定先是一個請求,請求中先收到url及版本號等,再收到header包頭,那傳送響應的時候也是先傳送line再傳送header再傳送body。那麼我就可以把這個邏輯抽象出來。接下來我們會重點看邏輯是如何抽象出來的。然後呢可能還有一些公共的工具,也可以由第一個core模組來做。
 
那麼http response的響應過濾也有許多共性的抽像,因為我們返回http協議內容的時候可以做很多事情,比如說做壓縮、生成縮圖,這個時候呢其實就是在body和header上做文章,所以nginx又引申出來一個概念叫響應過濾模組。我們現在看這張圖,先看左邊這邊傳播非常廣泛的官網圖片,從這上面到達一個internet request,首先讀完它的header頭部,讀完以後呢,這時候判斷我的安全模組是否應生效,包括用location正規表示式匹配url決定用哪些配置,我可能會做一些限速。生成response響應內容就是generate content,可能我讀本地的磁碟檔案實現,也可能我跟上游的一個服務互動獲取其內容等等,這個事情做完以後,我拿到了http response,此時開始對header和body進行過濾模組的處理,做完以後呢記錄access日誌,這個日誌用來做監控運維,最後把response返回給使用者。
 
再來看HTTP模組抽象出的11個階段。比如說現在有一個realip模組,如果需要你去實現這個模組去獲取使用者的真實IP,我不知道你會把該模組放在哪個階段?因為實際TCP連線它實際上是個四元組,當nginx與使用者中間有反向代理的話,其實你從連線的source ip獲取到的地址不是使用者的,怎麼辦呢?除了複雜的proxy protocol等方案外,應用層的HTTP協議很有辦法,它可以通過把使用者的原始ip在request http header中帶來。所以如果我們去實現這樣的一個http模組,可以搞一個nginx變數去存取header頭部的ip值,這個http模組到底放在11個階段的哪個階段就是個問題,因為前面的這些模組都有可能去改http header的,只要其他http模組有能力去改,realip模組獲取到的ip可能就有問題。所以,在post_read模組,顧名思義,就是剛讀取完http request header,此時去處理最合適。再到下面是rewrite相關的三個階段,一個叫find config在其中。rewrite模組是官方模組處理的。
 
接下來看訪問控制相關的3個階段。比如說輸入使用者名稱和密碼自然在access階段,但是如果說你要做流控,你肯定要在他之前,否則的話這個流控可能就失效了,請求流量有機會打到access階段這裡。因此流控必須在pre_access階段。OK現在看一下這個http模組的工作流程,我做了一個動畫,怎麼看呢?先從左邊看這個綠框,這個綠框表示master程序在啟動,它首先讀取nginx.conf配置檔案,這裡會依次從上至下讀取這個ascii檔案,當它發現events {}配置時,自動將大括號裡的內容交給event事件模組解析,而發現http{}時就交給http模組處理,stream{}裡的內容自然交給stream模組處理。對於http模組而言,有些http模組會根據配置項決定是否將其鉤子函式新增至處理流程中。http core模組會負責將所有的location建立為一顆樹以加快訪問速度,最後還要將listen後跟著的埠加入容器中,接著讀完配置檔案就開始listen開啟監聽埠,再fork出子程序。子程序worker會繼承父程序已經開啟的控制代碼,自然也包含埠。master程序接下來就只監控worker子程序,以及等待接收訊號命令好了。而worker程序則只處理事件以及接收master發來的訊號命令。當worker程序收到SYN包,開始建立連線,請求處理流程就開始了。首先用HTTP狀態機確保接收到完整的HTTP的header頭部,再依次呼叫剛剛介紹過的11個階段的HTTP模組去處理請求,在content階段處理完後生成了http resposne,再呼叫各http filter模組加工response,最後傳送出去。
 
再看看怎麼找到location下的配置去處理請求的。tcp連線是四元組,所以可以根據lister時的ip address去獲取連線,尋找由哪些server{}去建立連線,就像圖中的最左邊,其配置如下所示:
server {
listen localhost:80; listen 8000;
server_name zlddata.com zlddata.cn;
location /static { ... }    location / { ... }
}
Server {
listen      80;
server_name fuzhong.pub;      ….

接著,我們接收完http request header後,可以從HOST頭部獲取到域名,而這可以匹配server_name配置後的虛擬主機域名列表,這樣就唯一確定了一個server{}配置塊。從URL中還可以再次匹配location後的正規表示式,這樣我們就找到了具體的location配置。

再看這張圖,我們談談11個階段間http模組間的配合。這裡僅以官方模組舉例。當一個請求讀完http header後,我們先進入preaccess階段,這一階段裡有兩個模組:limit_conn和limit_req模組。前前限連線,後者限請求。可見,前後順序亂不得,否則就導致limit_conn無法正常生效了。當limit_conn模組決定請求不受限制後,它會返回NGX_OK給鉤子函式,這樣進入當前preaccess階段的下一個模組limit_req模組繼續處理。而limit_req模組也認為不受限制,可以繼續處理,因為當前preaccess階段沒有其他模組了,故進入下一階段access階段繼續處理。而在access階段中,若第一個模組auth_basic認為無須進入下一個access模組處理,那麼它可以返回NGX_AGAIN給鉤子函式的呼叫者,這樣access階段其後的模組是得不到執行的。可見http模組還是很靈活的。當content階段生成內容後,首先由header filter模組處理。為什麼呢?因為http是流式協議,先返回header,再返回body。比如我需要做壓縮,那麼就需要先在header中新增content-encoding頭部,再壓縮body。這裡需要注意的是,這些模組間也有順序要求!比如現有一張圖片,你只能先做縮略做再壓縮,如果反過來,壓縮後是沒辦法做縮圖的。所以這個順序也是由configure這個指令碼決定的,大家可以看原始碼時看到裡面的註釋明確的寫著不能改order順序。

這張圖是openresty官方的圖。用好openresty的關鍵是,搞清楚指令與sdk。其中sdk比較簡單,就是形如ngx.xxx這樣的函式,可以在lua程式碼中呼叫。它實際上就是lua與C語言的互動,通過先在nginx模組中提供相應的函式,再封裝給lua作為lua函式即可,目前主要在用ffi方式,最新的openresty都在用ffi方式重構。而指令就是nginx配置,它會決定其中{}大括號內的程式碼在什麼時候執行。這張圖中,有初始nginx啟動階段、有rewrite/access階段、有content階段以及log階段。這與我們之前的所說的11個階段有什麼關係呢?

我們來看這張圖,有點複雜,最上面的綠框是nginx啟動過程,其中黑色的框是master程序,而紫色的框是worker程序,中間的紅點是鉤子函式。中間的紫色框是worker程序在處理請求。最下面的綠色框是nginx在退出。可以看到,當nginx啟動在,通過在配置檔案中各第三方模組可以介入,在init_module回撥函式實現東西也可以介入nginx的啟動。當派生出worker子程序後,仍然可以通過回撥init_master、init_process等回撥方法介入啟動過程。而實際處理請求時,先可以通過8個http階段介入與請求的處理,在content階段還可以使用排他性的r->content_handler(用於反向代理)來生成響應內容。在生成響應內容時,還可以通過init_upstream鉤子函式決定選擇哪一臺上游伺服器。生成響應內容後,通過filter過濾模組也可以介入請求的處理,最後在access log階段也可以介入請求的處理。在nginx退出時仍然可以介入處理。

而openresty的指令就是像圖中這麼介入處理的。例如,rewrite_by_lua實際是在post_read階段介入處理的,因為就像上面說過的,rewrite階段都是官方模組在處理,所以openresty實際是在postread階段,所以這個指令是相當靠前的。而balance_by_lua實際是在init_upstream鉤子裡介入的。

最後我們看一下nginx變數。有一類模組會生成新的nginx變數,它們通過處理http請求時定義的取值方法,生成了變數名對應的變數值,並以$符號或者lua中的ngx.var等方式提供給使用變數的模組。這些模組既包含C模組,也包括lua模組。C模組更關注高效,往往提供變數的模組都是C模組,而lua模組關注業務。所以,這兩類語言最好的解耦方法就是使用變數。

最後我們看看第三部分nginx效能的優化。我們希望nginx可以把一臺伺服器的效能壓榨到極致,主要從5個方面入手。首先是不能有長時佔有CPU的程式碼段。因為nginx是事件驅動的、非阻塞的、非同步架構程式碼,就像圖中所示,nginx把本來作業系統應該做的事:切換不同的請求處理,改為在nginx程序內部處理了。怎麼講呢?傳統的程序是同一時間只處理一個請求,所有處理請求的方法都是阻塞的,所以在處理完一個請求前不會處理下一個請求。因此,當大量併發請求存在時,意味著大量執行中的程序或者執行緒。作業系統希望最大化吞吐量,它就會切換不同的程序到CPU上執行,當一個程序因為阻塞請求導致的系統呼叫不滿足時,例如讀取磁碟轉頭磁頭,就會被切換到記憶體中等待下次執行。而nginx採用的事件驅動,則是把這一過程放在nginx的使用者態程式碼內了,首先用非阻塞系統呼叫檢測到條件不滿足,如果執行會導致作業系統執行程序間切換時,就會把該請求切到記憶體中等待下次執行,而nginx會選擇條件滿足的請求繼續執行。因此,如果處理一個請求時消耗了大量的CPU時間,就會導致其他請求長時間得不到處理,以至於大量超時,形成惡性迴圈。所以,遇到某些第三方模組會大量消耗CPU時務必謹慎使用,真有這樣的場景也不應當在nginx中做,可以用nginx反向代理到多執行緒應用中處理。因為作業系統會為每個程序分配5ms-800ms的時間片,它也會區分IO型或者CPU型程序,而上述程序是明顯的CPU型程序,上下文切換不會很頻繁。

第二個優化點就是減少上下文切換。在這頁PPT中我們提到一個工具叫pidstat,它可以清晰的看到主動切換與被動切換。何謂主動切換呢?就是執行了某些阻塞式系統呼叫,當條件不滿足時核心就會把程序切換出去,叫做cswch/s。而作業系統微觀上序列巨集觀上並行實現的多工,是使用搶佔式核心實現的,它為每個程序分配時間片,時間片耗盡必須切出,這就叫nvswch/s。我們通過增加程序的靜態優先順序來增大時間片的大小。靜態優先順序分為40級,預設程序是0級,最大是-19,我們可以在nginx.conf裡修改靜態優先順序。另外,還可以通過把worker程序繫結CPU,減少在多核伺服器上的程序間切換代價。對於主動切換,則需要減少使用類似nginx模組的場景。有時這很難避免,例如讀取靜態檔案,當頻繁讀取的內容打破記憶體快取時,使用nio或者sendfile也沒有用,仍然退化為阻塞式呼叫,此時用threadpool執行緒池就很有意義了,官方有個部落格上提到此種場景下執行緒池有9倍的效能提升。當然,目前執行緒池只能用於讀取靜態資源。

第三個優化是減少記憶體的使用。很多併發的連線是不活躍的,但它們還是會在核心態、使用者態佔有大量的記憶體,而總記憶體其實很有限,所以我們的記憶體大小及各種記憶體相關配置影響了我們的併發量。先從連線談起,在Nginx程序內為每個連線會分配一個ngx_connection_t結構體,每個ngx_connection_t各分配一個ngx_event_t結構體用作讀、寫事件,在64位作業系統下以上結構體每個連線(無論是TCP還是UDP)消耗的記憶體是232 96*2位元組。在作業系統核心中,為了處理複雜的TCP協議,必須分配讀、寫緩衝用於程序的讀寫、滑動視窗、擁塞視窗等相關的協議收發,而linux為了高效使用記憶體,設立了普通模式和壓力模式,即記憶體寬裕情況下為每個連線多分配一些快取以提高吞吐量,在壓力模式下則每個連線少分配一些快取以提高併發連線數,這是通過tcp_moderate_rcvbuf開關控制的,而調整幅度可通過tcp_adv_win_scale控制,調整區間在讀寫快取上設定。Nginx中含有大量記憶體池,形如*_pool_size都是在控制初始記憶體分配,即必須分配出去的記憶體。還有一類分配如8 4K這樣的多塊不連續記憶體,比如對於large header或者gzip buffer等,它們使用ngx_buffers_t結構體儲存。共享記憶體是用於跨worker程序通訊的,而openresty裡的share_dict就是通過共享記憶體實現的,當然使用共享記憶體通常要用slab夥伴系統管理記憶體塊,再用rbtree紅黑樹或者連結串列等資料結構管理實際的邏輯。Nginx中還會用到大量的hash表,比如儲存server_names等,這裡會定義桶大小和桶個數。

第四個是優化網路。我們先從TCP層面看,無非是讀、寫訊息、建立與關閉連線等功能。如果是讀訊息,我們需要關注tcp_rmem設定快取區的大小,需要關注初始擁塞視窗rwnd的大小以提升網路可以快速達到最優值。在nginx上還有許多控制讀取到固定訊息的超時時間,在讀取上游服務發來的響應時還可以通過limit_rate限流。在傳送訊息時,同樣可以設定tcp_wmem設定快取區大小,通過iptables命令對cwnd來提升初始視窗,nodelay和nopush都是為了提升吞吐量的演算法,當然它們的副產品就是犧牲了及時性增大了latency。總體來說對於大流量場景應該開啟它們。當然nopush只對sendfile有效。當傳送響應給客戶端時,也可以通過limit_rate進行流控。作為伺服器建立連線時,Tcp Fast Open技術可以在SYN包裡就攜帶請求,這減少了一次RTT,但有可能帶來反覆收到相同包的情況,一般不開啟;使用Defered可以減少nginx對某連線的喚醒次數,提升CPU使用效率;reuseport可以提高負載均衡效果,使多worker程序更好的協同工作;backlog可以增大半連線與全連線佇列,特別是新連線很多而Nginx worker非常繁忙時。關閉連線最複雜,特別是nginx主動關連線時,fin_wait_1狀態下linger可以控制關連線的時機以減少RST包的傳送;tcp_orphan_retries控制傳送次數。fin_wait_2狀態下可通過tcp_fin_timeout控制超時時間。在time_wait狀態下通常會開啟tcp_tw_reuse提升埠的利用率,但tcp_tw_recycle會使得time_wait狀態近乎消失,這會帶來埠複用時被丟包補發的FIN包關閉連線。

最後是減少IO呼叫。這隻對讀取本地的靜態資源有效,例如開啟sendfile採用了零拷貝技術就減少了記憶體拷貝次數以及程序間上下文切換次數。

最後對今天的演講做個總結。我前陣子聽樑寧的30堂產品課,上面說到看待產品或者人都是從5個層面,我覺得很適用於nginx。首先從表面層。例如相親時先對異性的長相、談吐、衣著,而看nginx則是看它的配置檔案格式、access.log日誌格式、程序啟動方式等。第二層是角色層,例如與一個同事溝通,那麼他是HR或者是前端工程師,都會影響他的談吐以及溝通方式。而nginx的角色層就是最開始提到的靜態資源服務、反向代理、API伺服器,這影響它的表現層。第三層是資源層,對人則是人脈資源、精神資源、知識結構等,而對nginx則是它的大量的第三方模組、社群等。第四則是能力圈,對人就是一個人的能力大小,對nginx就是上文提到的nginx的核心架構、模組化、設計思路、演算法、容器等。第五層是最核心的存在感,對人則是什麼狀態能讓人滿足,對nginx則是它的設計意義,就是我們前面提到的把一臺伺服器的硬體能力使用到極限以提供強大的web服務能力。這裡底層總是在影響著上層,所以當我們掌握了nginx的底層,無論上層怎麼變都很容易理解。

OK最後我在這裡做個廣告,智鏈達是一家杭州的創業公司,是希望助力一個行業實現轉型升級的網際網路服務公司,目前各種崗位都在招聘,我們其實面向的是一個藍海行業,希望各位有興趣的同學考慮加入。行,那我今天的介紹到這。