2018 年 11 月 17 日,由 OpenResty 主辦的 OpenResty Con 2018 在杭州舉行。本次 OpenResty Con 的主題涉及 OpenResty 的新開源特性、業界最佳實踐、性能優化、Trace、 API 網關等方面。
又拍雲受邀參加 OpenResty Con 2018,又拍雲系統開發工程師張超在大會上做了《又拍雲 OpenResty / Nginx 服務優化實踐》的開場演講。
又拍雲在自身業務中大量使用了 Nginx、OpenResty,使用兩者開發了雲處理、雲存儲業務平台,並在又拍雲 CDN 平台中使用 ngx_lua 作為反向代理服務, CDN 平台的 API、文件上傳服務通過 ngx_lua 對大文件進行流式上傳,利用 ngx_lua 對網絡線路進行動態的調度,自動選擇最合適的線路進行上傳加速。同時,又拍雲在 Github 上也一直保存、更新着又拍雲所報告和修復的關於 Nginx 和 OpenResty 的 bug,是 OpenResty 重要的貢獻者。
△ 又拍雲系統開發工程師張超
張超,又拍雲系統開發工程師,負責又拍雲 CDN 平台反向代理層組件的開發和維護。Nginx、OpenResty 等開源軟件愛好者。本次分享着重介紹了又拍雲 CDN 平台在不斷的更新迭代中總結出的關於 OpenResty 和 Nginx 服務優化的經驗,包括構建容器化的生產環境性能分析環境、集成 SSL 硬件加速至 OpenResty 及其陷阱等案例。
分享主要分三塊展開:
- 關於性能分析、 Tips 以及使用 Nginx 編碼的注意點,使應用代碼能夠獲得更好的運行性能;
- 又拍雲對 DNS 的解析管理;
- SSL 硬件加速的實踐,側重介紹在使用過程中與 LuaJIT 的沖突及解決思路。
以下是分享實錄:
又拍雲和 OpenResty 的那些事
又拍雲從 2013 年就開始關注並且使用 OpenResty ,是最早使用 OpenResty 的廠商之一。
△ 又拍雲 CDN 架構與 API、雲處理架構
上圖左邊是又拍雲 CDN 的簡單架構圖,右邊是又拍雲的 API、雲處理方面的架構圖。圖中除了 Cache Server,其他所有的服務全部是基於 OpenResty 實現的,包括又拍雲存儲的網關。去年,又拍雲引入了 Kong,將它作為公司的 API 服務,日志上報、流量上報、監控數據上報等服務的統一入口網關,並做了統一的認證,給我們省了不少的事情。
upyun-resty 這個 github 倉庫記錄了又拍雲所開源的 lua-resty 庫、C 模塊以及又拍雲所報告和修復的關於 OpenResty 和 Nginx 的 bugs。
大家如果有興趣的話可以瀏覽這個頁面:https://github.com/upyun/upyun-resty
從 2013 到 2018 年,又拍雲的業務發展愈加全面,不得不去做更深入的一些優化。業務發展到了一定階段,必然會遇到性能瓶頸,而這些問題就交由我們這些工程師們來解決。
性能分析、Tips
我們經常會在線上或本地開發機器上執行 top、pidstat 等命令,這些命令會報告一些系統和進程的資源使用情況,這就是資源分析。資源分析主要聚焦於資源利用率、飽和度以及錯誤數,這就是經典的 USE 分析方法。這個分析方法由 Brendan Gregg 大神提出,非常有用。通過資源分析可以非常直觀的看到業務應用在線上跑着,會吃掉多少的內存,占用多少的 CPU,產生多少磁盤吞吐等。
第二個是針對應用程序本身所展開的工作負載分析,工具也比較多,包括 perf,systemtap,bcc/ebpf 等工具。通過 systemtap、bcc/ebpf 可以將數據進行匯總,利用工具畫出火焰圖。負載分析主要是針對應用程序自身的表現,比如 OpenResty,在作為一個反向代理服務器或者網關存在時,大家比較關心它的指標就是請求延時和狀態碼,像 501、502、504 狀態碼的比例。
當我們需要展開分析時,會發現線上環境,工具等條件不足,甚至連基本的編譯工具都沒有,這時候像 systemtap 這樣的工具就沒法用了。又拍雲也遇到了這樣的問題,因此我們采用了容器化,把所有的工具全部扔到了容器里,包括一些基本的編譯器,鏈接器,常用的 perf、systemtap、gdb、mozilla rr 等工具。大家可能會覺得容器的權限會不會有問題,當然首先這個容器必須是以特權模式去運行,另外還需要把宿主機的內核頭文件進行映射,並且必須使用宿主機的 pid namespace,否則在容器里面沒有辦法去追蹤宿主機上的進程。
△ 容器案例
上圖例子中運行的容器使用的是 upyun_stap 的鏡像,跑起來后可以在這個容器里面做任何的事情,但是它對內核是有一定的要求,如果想使用 systemtap,內核就必須把一些相關的選項打開。這是我們一個小的思路,如果遇到環境工具不足的情況,可以考慮采用容器化的方法。
ngx_lua 編程怎樣挑選合適的 API 獲得更好的編程體驗
介紹一下 ngx.ctx 和 ngx.var,這兩個 API 一般用於儲存和請求相關的數據,它們的數據都是關聯到某個請求。ngx.ctx 本質上就是一個 Lua table,后者 ngx.var 實際上是一個獲取 Nginx 變量的接口,通過它可以設置和獲取變量。相比較而言, ngx.ctx 是更好的選擇,首先 Lua table 的實現非常快,雲風曾在一篇文章里提過,Lua table 查詢一次的耗時,幾乎等於對 key 進行一次 Hash 的過程。其次 ngx.var 返回的是 Nginx 變量的值,它的返回值只能是字符串,類型比較單調,如果存一些結構化的數據,就不得不去進行序列化,從而會帶來一定的開銷,並且在 ngx.var 的接口內部還有一些 Hash 的計算、內存的分配等。
△ Tips
分享一個 Tips,我們可以適當的 cache 一下 ngx.ctx,去避免相對比較昂貴的 metamethod 元方法調用。比如上面這個代碼片段,先把 ngx.ctx 進行 local,然后再對它進行一些復雜和取用,這樣就避免了額外的 metamethod 調用。
ngx.ctx 也有一些缺陷,最大的缺陷就是生命周期只能局限在一個 Nginx location 當中。換言之,如果業務比較復雜,Nginx 發生了內部重定向,存儲於 ngx.ctx 內的數據都會被丟失,所有模塊的上下文都會被清掉,因為 ngx.ctx 實際上是存在 ngx_lua 模塊的上下文當中。所以在發生一次內部調用之后,數據就再也獲取不到了。這個問題去年我們在 github 也問過,后來用 Lua 實現了一個折中的方案,我們模擬了 ngx.ctx 的實現過程,將它保存在我們自己所創建的表里,然后將它的索引值保存到變量中,因為變量的生命周期是不受 location 影響,而是貫穿整個請求的,不管請求中間發生過多少次的內部跳轉,變量都是一直存在的。
△ 方法案例
舉一個例子,上圖從 location / t1 跳轉到了 t2,數據還是可以從 location /t 2 中獲取出來。通過這個方法,就可以保證即使發生了內部跳轉,依舊可以獲取數據。
HTTP headers 操作
介紹大家在用 OpenResty 的時候,必然是離不開操作 HTTP 頭部的,主要的接口就是 ngx.req.get_headers,這個接口會返回所有的請求頭,如果說沒有給它指定 raw 參數,所有的名字都是小寫的形式存在的。如果你傳入的請求頭名稱不是全小寫的,第一次是獲取不到的,它會把這個全部轉換成小寫,再去訪問一次。因此在這個過程中,我們會訪問表兩次,而且中間有一次經過了元方法的調用,在針對這個事情上有一個比較好的辦法,那就是手動的把它的元表去掉,然后在訪問的時候全部使用小寫的形式。這樣就可以省掉元方法的調用,可能一次元方法、兩次元方法,看不出什么效果,但是當一個請求里面可能操作比較多的請求頭,同時 qps 比較高的時候,就能體現出一定的差距來。
然后是日志處理,我們大多都會采用 Nginx access log 模塊去管理訪問日志。它最大的弊端就是和磁盤、文件系統進行了交互,那么就有可能受到文件系統或磁盤的影響,如果磁盤抽風或磁盤利用率滿了,反過來就會影響到 Nginx 的進程。因為 OpenResty 和 Nginx 在寫磁盤的時候,是同步寫的,如果 access_log buffer 的參數設置不合理,會產生多次的 write() 系統調用,對應用程序本身是非常不利的。建議大家可以使用 lua-resty-logger-socket 組件,又拍雲也使用了這個工具,將日志發送到同一個機房同一個網段的其他服務,由其他的服務去上報日志,這樣可以避免 CDN 服務和磁盤直接進行交互,最大程度保障服務不會收到磁盤本身的影響。因為一塊磁盤有時候不僅一個服務在用,其他的服務也在用,如果其他的服務產生太多的磁盤寫入,就會反過來影響到 CDN 服務。
養成良好的編程習慣
- 不要濫用全局變量:在 ngx_lua 編程中,全局變量是在一張表里,雖然 Lua table 設計的很好,每次訪問都僅僅是一次表查詢,雖然速度很快,但是和本地變量的讀取還是有差距的,所以全局變量能不用就不用。
- 避免低效的字符串拼接操作:舉一個臭名昭著的例子,在 for 循環里去拼接字符串,會產生大量的內存分配,大量的內存拷貝,帶來 GC 開銷。
- 避免太多的 table resize 操作:大家在編程時,需要一個表就創建出一個空表,往里面寫數據,這個過程中會發生一些 table resize,表內部的 hash part 大小會發生改變。比較好的辦法就是使用 table.new 創建預先定義好大小的表。
- 使用 LuaJIT 和 lua-resty-core 的搭配,避免使用官方的 Lua 解釋器:LuaJIT 非常的強悍,所以也應該盡可能的去使用 JITable 的函數,同樣 LuaJIT 的 ffi 實現也非常的優秀,比使用原生的 Lua C stack 的交互方式更加的方便、高效。
DNS 解析管理
大家可能對 Nginx 本身的域名解析的理解上存在一些偏差,這里會介紹一下 Nginx 運行時所使用的 DNS 解析器的一些缺點,以及分享又拍雲基於 ngx_lua 所做的解決方案。
△ 案例
圖中虛擬主機的后端是寫在 upstream group 里面,只有一個 server 指向 http://upyun.com 域名,大家會覺得這樣寫完就沒事了。某一天 http://upyun.com 指向的那台機器崩掉了,匆匆忙忙去改解析,卻發現沒有生效,然后服務還是出現一 堆 502。因為這樣的一個域名,在 Nginx 或者 OpenResty 剛剛啟動的過程中,就已經解析完了,worker 進程在處理請求的過程中不會對域名的解析發生任何改變,其實解析在處理 server 指令的時候就已經完成了,這第一個誤區。這種情況下只能手動去重啟服務,如果服務像 CDN 那么多,會比較耗時和麻煩。第二如果不用 upstream 直接帶過去也是不行的,兩者實際上沒有太多的差別,Nginx 內部也會創造一個 upstream group,然后把域名解析好。所以這兩種方法,總的來說只會解析一次,使用的是 getaddrinfo 或者 gethostbyname,調用系統配置的 nameserver 去進行配置。
分享一個比較好的方法,可以強迫你的服務進行解析,就是在 proxy_pass 指令里使用變量,把整個解析過程推遲到運行時。proxy_pass 后面的那一串是一個復雜值,會進行一個變量的解釋,最后再對域名進行解析,這時候會使用到 Nginx 的 resolver,比如上面所定義的,定義了三個 nameserver。然而 Nginx 的運行時 DNS resolver 的問題也挺多的,DNS resolver 雖然支持輪詢,也支持 cache,可以簡單的緩存在內存中,但是不支持配置備用 name server,所有配置好的 nameserver 之后都會被使用。同樣 DNS resolver 也沒有實現故障轉移,最關鍵的是不支持在故障時使用陳舊數據。因為出現故障時,有數據肯定好過沒數據,即使解析是舊的,也能擋住一定的危害。
solver的運行情況
我們對 DNS resolver 不是非常的滿意,又拍雲的業務也都是用 Ngx_lua 所編寫的,索性就直接弄了一個 lua-resty-domain 的庫,它主要承擔的是域名的管理。它基於 OpenResty 官方的 lua-resty-dns (DNS 解析器的庫) 同時結合 Cloudflare 開源的 lua-resty-shcache (一個緩存組件的庫),也結合了又拍雲所開源的 lua-resty-checkups(主要是做心跳和負載均衡的庫)。全部結合起來以后,lua-resty-domain 能夠做到復雜的 load balancing,包括簡單的輪詢、帶權輪詢和一致性 Hash,或者是其他的一些負載均衡的辦法。lua-resty-domain 支持心跳功能,就是向目標的 nameserver 發起解析請求,看它的解析情況,以及是否超時等。 lua-resty-domain 也支持故障轉移,如果所有主 nameserver 都掛掉了,會去使用備用的。因為結合了 lua-resty-shcache 所以也支持 cache 功能,可以把解析出來的數據存在共享內存當中。最關鍵的是,lua-resty-domain 能在所有的 nameserver 掛掉的時候,提供一些陳舊數據,保證業務不會受到影響。綜合來說,這是一個比較好的解決方案。
△ lua-resty-domain 配置
在 cluster 塊中定義了一主一備的 nameserver,能在 DNS 解析方面提供一個高可用的解決方案。DNS 是每個服務里面不可獲缺的一部分,比如使用了公共的 DNS 的時候,無法保證公共DNS 時刻沒有問題, 比如某個時刻 DNS 服務器負載較高,解析延時上升反而影響到了業務,這就划不來了。所以萬事還是要從自己做起,做好一個完美的解決方案,盡可能的避免這樣的影響。
除了使用 lua-resty-dns 庫,ngx_lua 的 Cosocket,內部也提供了簡單的域名解析功能,使用的也是 Nginx 的 resolver,但我們不太想用。所以我們參考了一下 Kong,把 ngx.socket.tcp 的 connect 方法重寫了一下,先調用 lua-resty-domain 進行解析,再將解析出來的地址傳遞給原來的方法,強迫它使用我們所定義好的域名解析功能。雖然這些代碼沒有特別容錯,但是足夠表述出這種思想,這是一個非常好的 idea。
SSL 硬件加速實踐 BUG 解析
SSL Acceleration 會把應用中所涉及到的加解密、簽名等占用 CPU 的任務全部 offload 到像 Intel QAT card 這種具備計算能力的硬件上去,從而降低應用程序的負載。 Acceleration 這個詞比較有迷惑性,並不是說計算本身得到加速,而是說這種技術能夠讓應用程序的吞吐得到提升。尤其結合異步模式,采用異步的方式把任務全部都交付給硬件后,應用就可以處理其他請求了,這樣使得應用程序的吞吐得到了一個質的提升。
CDN 廠商最關注的就是 CDN 和帶寬的比例,CDN 跑到 80%、90%,但是帶寬一直上不去的話就完全利用不起來機房的帶寬,這對成本來說是不利的。如果在同等的 CPU 消耗情況下,能產生的帶寬和吞吐量上去了,對成本會比較有利。又拍雲所采用的就是 OpenResty+Asynchronous OpenSSL+Intel QAT 的組合方案。
△ 硬件加速前后數據
這張對比圖,上面是沒有使用硬件加速的數據,下面是使用了硬件加速的數據,分別做了一次RSA 私鑰簽名以及公鑰認證的過程,可以看到最后一行的對比,使用 QAT 卡之后私鑰簽名的過程至少提高了十倍,后面用公鑰認證也有八到九倍的提升。不過這是最理想的情況,是沒有任何業務的時候的得到的一個理論值,業務當中肯定是達不到的。
△ 實際 CPU 效果
這是我們監控中采集到的 CPU 的變化曲線,當時的連接數大概是三四萬個到 443 端口的連接。 CPU 的變化可能就沒有那么理想,大約降低了10%,因為其他的業務也需要占據一定的 CPU。集成到又拍雲這樣復雜的 CDN 系統中也算是一個非常不錯的提升了。
異步模式的 OpenSSL 和 LuaJIT 不能很好的共存,會出現應用程序崩潰的問題,這是非常不幸的。對此我們也排查了很久,究竟是什么導致的崩潰。
首先嘗試過將 LuaJIT 的 JIT compiler 禁用,結果是還會出現崩潰。也嘗試過關閉 OpenSSL 的異步模式,崩潰確實沒有出現了,但是同步的去做硬件 offload 過程,沒有比直接用軟件計算快多少,那就沒必要用這個方案了。另外我們在 ssl_certificate_by_lua* 階段有 ocsp 校驗過程,會有一些 Light Thread 的協程切換,我們也試過關閉 ocsp,但崩潰問題還是會出現。最后,我們甚至將服務中用到的其他一些組件全換成了原生組件,但問題依舊存在。
△ 流程圖
后來,LuaJIT 社區有一個哥們說我們的程序是不是存在這么一個情況。
首先 Nginx 要進行 SSL handshake,運行在系統棧上。異步模式 Open SSL 內部使用了 OpenSSL fibre,其實就是協程,啟用這個模式后,會涉及到 fibre 上下文切換的過程, 此時程序會運行到 OpenSSL fibre 的棧上,前面我提到了我們的程序在 ssl_certificate_by_lua 階段也有業務介入,那么反過來程序又會跑到了 LuaJIT 的棧上,從而可以運行應用代碼。應用代碼又調用了 ssl.set_der_cert 這樣的接口去設置證書,證書都是保存在 redis 這樣的外部組件里的,拿出來就需要設置進去。設置進去以后,會涉及到一些摘要計算,此時程序運行到了 OpenSSL fibre 的棧上去了,在交付給硬件以后,OpenSSL fibre 可能會將自己切出,所以此時應用程序又回到了系統棧上,即握手之前的位置,然后 Niginx就非常愉快的去處理其他的請求,其他請求也是類似,所有的業務代碼都是由 LuaJIT 運行的,某個時刻就又跑到了 LuaJIT 的協程棧上。
整個的過程簡單來說,就是我們重入了 LuaJIT,但是並沒有調用到 lua_yield() 函數。
Lua 是用 lua_resume() 和 lua_yield(),去進行協程切換的。我們相當於違反了這個規則,重入到了 LuaJIT,發生了不可預期的問題。
△ 禁用代碼
目前想到的最好的辦法就是在 ngx_lua 和 ssl 相關的 API 里去禁掉上下文切換。所幸 OpenSSL 異步模式提供了相關接口,我們在相關的函數里加入了兩段代碼,重新編譯完放上去后,應用程序就開始正常的工作了。
我的分享就到這里,希望大家遇到同樣的問題能有一些啟發。
推薦閱讀: