本文講述基于 OpenResty 的接口網關設計,主要談及接口網關的請求路由與安全認證(IP 與 URI 白名單、加解密與驗簽名流程等)這兩部分內容,其中涉及到的 Nginx、OpenResty等相關內容會作簡單介紹。
〇、前言
筆者曾參與開發兩個接口網關的項目,一個是基于 Tomcat 的應用提供的網關服務,另一個是基于 OpenResty 的 Nginx 應用提供的網關服務。經過兩個網關項目的開發,筆者在接口網關開發方面稍微積累了一些經驗,故在此把這些經驗分享出來一起交流學習。由于基于 OpenResty 的 Nginx 網關普遍被認為是更優的方案,故本文主要針對基于 OpenResty 的 Nginx 網關進行講述。當然,由于不同的并發數量級,不同的業務場景,接口網關的設計多種多樣,本文所述其中較為簡單且輕量級的一種。
一、什么是接口網關

1.1 定位
接口網關,顧名思義,是企業 IT 在系統邊界上提供給外部訪問內部接口服務的統一入口。這里的外部可以指客戶端、瀏覽器或者第三方應用等,在這種情況下,接口網關可以有多種定位:
- 提供后端服務面向 Web App 或者 Mobile App 的 APIGateway
- 作為開放平臺面向 Partner 的 OpenAPI
- …
在筆者的工作中,同樣把面向客戶端的網關稱作 APIGateway,把作為開放平臺提供給第三方服務的網關稱作 OpenApi。本文主要以 OpenApi 作為接口網關為例來講述。
1.2 功能
作為企業 IT 系統的統一入口,接口網關可提供請求路由與組合、協議轉換、安全認證、服務鑒權、流量控制與日志監控等服務。在筆者的工作中,主要在接口網關上實現了請求路由與安全認證的功能,題目中所說的“設計”,主要是指請求路由與安全認證方面,暫不涉及流量控制或日志監控等其他方面的設計。
二、為什么需要接口網關
正如上文所言,網關接口為企業應用提供了豐富的功能,而筆者在工作中開發的接口網關主要提供請求路由與安全認證的功能,那么在回答“為什么需要接口網關”的時候,需要對這兩者多加闡述。
2.1 請求路由
企業提供內外兩網,在沒有接口網關時,提供外部服務的應用需要部署在外網。隨著服務的增多,部署在外網的應用越來越多,在服務的安全壓力與維護成本增大的情況下,需要一個統一的接口網關“隔離”內外服務。企業提供的服務(無論內部服務還是外部服務)均部署在內網,而由部署在外網的網關接受請求,并路由到內網服務。在這種情況下,既有利于對外屏蔽企業內部服務部署細節,提供統一的服務訪問地址,又便于管理與維護內外部服務接口,便于演進與重構服務。這是接口網關提供請求路由的作用。
2.2 安全認證
在沒有接口網關時,企業對外服務直接由外部訪問,身份驗證與數據加解密等工作都需要每一個對外服務本身去處理,增加了服務本不該有的職責,并且增加了服務開發的難度與工作量。實際在大多數情況下,可以將身份驗證與數據加解密等安全工作可以從服務抽離,統一由接口網關負責處理。接口網關作為入口,對外驗證調用方的 IP,身份以及接口訪問權限等,并且可以解密數據后再將請求路由到服務。這是接口網關提供安全認證的功能。
以上是實際工作中涉及的為什么需要接口網關的其中兩個原因,當然原因遠不止此,有興趣的讀者可以閱讀其他文章,比如 《談API網關的背景、架構以及落地方案》 或者 《微服務:從設計到部署》(英文原文:Microservices: From Design to Deployment)。接下來的章節我們開始探討如何開發接口網關。
三、如何開發接口網關
我們先看看工作中設計的提供請求路由與安全認證功能的接口網關的架構。
不過在介紹接口網關的設計之前,我們先來了解一下關于 Nginx 與 OpenResty 的基礎知識。
3.1 Nginx 與 OpenResty 簡介
3.1.1 Nginx 簡介
Nginx 是世界第二大 Web 服務器,僅次于 Apache,然而由于其極高的性能可處理海量的互聯網請求,現在已經成為業界高性能 Web 服務器的代名詞。
它的主要特征是高性能、高擴展性、高可靠性、低內存消耗、單機支持 10 萬以上的并發連接,支持熱部署,以及使用最自由的 BSD 許可協議。其中,Nginx 可以處理高并發壓力下的并發請求的原因如下:
- 事件驅動模型設計
- 全異步的網絡 I/O 處理機制
- 極少的進程切換
- 內存消耗低,極度“壓榨”服務器硬件資源
除了基于事件驅動的架構使其支持百萬級的 TCP 連接,另外高度模塊化的設計和自由的許可證使其擁有非常多擴展其功能的第三方模塊,也是它的重要特性。所以,后來才會有 OpenResty 的誕生。
我們看一個 Nginx 作簡單配置來提供服務的例子:
worker_processes 1;events { worker_connections 1024; } http { upstream backend { server 127.0.0.1:8080 } server { location / back { proxy_pass http://backend; } } }
上述配置文件中,分別在 event、http、server 以及 location 塊配置項中做了一些簡單的配置,當安裝完并啟動 Nginx 后(監聽 80 端口),訪問到 /back 路徑下的請求會被轉發到本地 127.0.0.1:8080 服務上。
3.1.1 OpenResty 簡介
根據官網定義,OpenResty 是一個通過 Lua 擴展 Nginx 實現的可伸縮的 Web 平臺。其核心是基于 Nginx 一個 C 模塊將 Lua 語言嵌入到 Nginx 服務器中,對外提供一套完整的 Lua Web 的 API,并透明支持非阻塞 I/O,提供協程 —— “輕量級線程”、定時器等,從而極大地降低了高性能服務端的開發難度和開發周期。
OpenResty 將兩個極為優秀的組件 Nginx 與 Lua 進行糅合,一方面保留了 Nginx 高性能 web 服務特征,另一方面有提供 Lua 特性在極少損失性能情況下便于業務功能的開發。根據官網介紹,OpenResty 非常便于用來搭建能夠處理超高并發、擴展性極高的動態 Web 應用、Web 服務和動態網關。
我們也是因為 OpenResty 的這些特性,特別是它對搭建動態網關的友好支持,才選擇了基于 OpenResty 來開發我們的接口網關 —— APIGateway 與 OpenApi。
開發接口網關使用到的 OpenResty 一個重要知識:OpenResty 對于一個請求的處理流程。Nginx 把一個請求分為不同的階段,從而讓第三方模塊通過掛載行為在不同的階段來定制自己的行為;OpenResty 擁有同樣的特性,不過在不同階段掛載的是 Lua 腳本。下圖是基于《OpenResty 最佳實踐》原圖重繪而來:

從上圖可知,OpenResty 處理請求大致分為四個階段:
- 初始化階段(Initialization Phase)
- 重寫與訪問階段(Rewrite / Access Phase)
- 內容生成階段(Content Phase)
- 日志記錄階段(Log Phase)
我們看一個 OpenResty 作簡單配置來提供服務的例子:
worker_processes 1;events { worker_connections 1024;}http { resolver 127.0.0.1; lua_package_path '$prefix/lua/?.lua;;'; init_by_lua_block { # ... } init_worker_by_lua_file lua/init_work_by_lua.lua; server { listen 80; location / { rewrite_by_lua_file lua/rewrite_by_lua.lua; access_by_lua_file lua/access_by_lua.lua; proxy_pass http://<url>; } }}
上述配置文件中,分別在 event、http、server 以及 location 塊配置項中做了一些簡單的配置,當安裝完并啟動 Nginx 后(監聽 80 端口),首先執行 init_by_lua_block、init_worker_by_lua_file 進行初始化,接著接受請求,所有的請求都會匹配上 "/" 路徑,進而執行 rewrite_by_lua_file、access_by_lua_file進行重寫與訪問,最后轉發請求到本地 127.0.0.1 服務上。
在實際的接口網關開發中,我們主要是使用到了 OpenResty 中初始化階段的 init_by_lua*、init_worker_by_lua*、重寫與訪問階段 的 rewrite_by_lua*、access_by_lua* 以及內容生成階段 content_by_lua* 過程。
3.2 接口網關的架構
這一節是本文的核心內容,重點講述接口網關的架構設計。如前文所述,本文主要以 OpenApi 為例來講述接口網關的架構設計。先看圖:

下面我們來一步步來分析架構圖的各個部分,首先是兩層的 HAProxy 。
3.2.1 兩層 HAProxy 代理
根據維基百科定義,HAProxy 是一個使用 C 語言編寫的自由及開放源代碼軟件,其提供高可用性、負載均衡,以及基于 TCP 和 HTTP 的應用程序代理。

如圖所示,隔離的內網與外網上分別提供了 HAProxy 代理, 外層暫且稱為 HAProxy internet ,內層稱為 HAProxy internal。外層暴露于外網中,使用統一地址如 http://openapi.company.com 來接受外部請求(這里指第三方的請求);中間是基于 OpenResty 的 Nginx 網關層,外部請求經過網關后通過 HAProxy internal 轉發到內網的服務上,內網服務遵循 Restful 風格,網關轉發到內網的地址由接口網關控制。
然而,目前的代理架構受到了當前整體架構的約束,實際上兩層的 HAProxy 代理并不是必需的。
- 對于外層 HAProxy internet,由于我們使用了與 HAProxy 緊密結合的 Openshift 架構,所以多了一層 HAProxy 的轉發;一般情況下,基于 OpenResty 的 Nginx 網關層可以直接在外網上提供服務。
- 對于內層的 HAProxy internal,由于我們當前還沒有實現服務治理,所以需要內層的 HAProxy internal 進行一層轉發;當實現了服務治理,可以消除內層 HAProxy 代理,減少轉發消耗。
在我們當前的系統量級下,這兩層 HAProxy 轉發消耗非常小可以被接受,所以調整架構的優先級還不高,以后再慢慢演進。
3.2.2 接口網關
接下來這一節是最為重點的接口網關的設計。接口網關主要利用前文所述的 OpenResty 執行階段對請求與響應進行流程處理,包括接口地址的重寫,IP 與資源白名單的控制,請求的解密與驗簽,請求的路由以及響應的簽名與加密等。
這里分成主流程,配置服務,安全服務三部分進行講述。
3.2.2.1 主流程設計
主流程是網關的核心,是請求處理的控制中心;它是通過 OpenResty 的 Lua 腳本處理流程來實現對請求的處理。

A. 主流程
- 在 OpenResty 服務啟動之后,首先通過 init_by_lua_block 階段初始化常量(包括調用配置服務以及安全服務所需的主機地址、端口、URL 地址等)、引入依賴(包括常用的 http 以及 cjson 依賴等)等作為全局使用;
- 接著通過 init_worker_by_lua_file 階段設置定時任務調用內網配置服務來緩存配置,為處理第三方的請求做準備,其中加載的配置可供 URL 重寫(即接口映射)、IP 以及資源(URI)白名單限制、請求的解密驗簽以及響應的簽名加密使用,詳情查看配置服務一節。
- 當第三方請求通過 HAProxy Internet 進入到網關后,根據配置通過 rewrite_by_lua_file 階段做 URL 重寫(即接口映射)。
- 服務接口 URL 發生變更,為了兼容舊的第三方調用,需要重寫第三方請求 URL 到新服務接口上
- Restful 接口的 Path Variable 在 Nginx 環境與在 Tomcat 環境上正則匹配的差異
- 需要重寫的原因可能有:
- URL 重寫后,通過 rewrite_by_lua_file 進入訪問控制階段,此時根據授權的第三方 IP 白名單列表,授權予第三方的開放接口列表,校驗請求的 IP 以及 URL。
- IP 與 URI 校驗通過后,同樣在 rewrite_by_lua_file 階段根據配置調用內網的安全服務進行請求的解密與驗簽,獲取明文。
- 在 content_by_lua_file 階段通過 ngx.location.capture 將原請求頭部信息以及參數等信息封裝到子請求中,借助自請求轉發原請求到開發接口服務中。
- 注意:根據官方文檔說明,ngx.location.capture 發送子請求會緩存響應在內存中,直到整個請求處理結束。那么,當有響應報文特別長或者請求并發非常高時,需要使用 cosocket 來替代 ngx.location.capture,避免因內存不足造成網關服務失效。
- 同樣在 content_by_lua_file 階段根據配置調用安全服務進行響應的簽名與加密,獲取簽名與密文返回給第三方。
- B. 文件結構
- 項目的大致結構如下,主要分為 Lua 代碼目錄和環境配置目錄。
--openapi --lua --access_by_lua.lua --cache_management.lua --content_by_lua.lua --init_work_by_lua.lua --rewrite_by_lua.lua --security.lua --prod --Dockerfile --nginx.conf --sit --Dockerfile --nginx.conf --README.md
- C. 主流程在 conf 中的配置
# Nginx worker 進程個數,直接影響性能。# 如果確認不會出現阻塞式調用,那么有多少 CPU 內核設置多少個進程# 如果有可能出現阻塞式調用,需要配置多一些進程worker_processes 1; events { worker_connections 1024;}http { # 內網地址 resolver xxx.x.x.xxx yyy.y.y.yyy; # 日志格式配置 log_format graylog2_format '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" ' '<msec=$msec|connection=$connection|connection_requests=$connection_requests|millis=$request_time>'; # 日志路徑配置 access_log syslog:server=<host>:<port> graylog2_format; error_log syslog:server=<host>:<port> warn; # 配置 Lua 包地址 lua_package_path '$prefix/lua/?.lua;;'; init_by_lua_block { # 引入依賴(可能會污染全局環境,待研究) http = require "resty.http" cjson = require "cjson" cache_management = require "cache_management" ... } # 設置定時任務緩存配置,及上面的 cache_management 模塊 init_worker_by_lua_file lua/init_work_by_lua.lua; # Nginx Web 服務配置 server { listen 80; # ngx.location.capture 子請求代理,轉發原請求到接口服務 location = /ngx_proxy/ { internal; proxy_set_header Accept-Encoding ''; proxy_pass http://$context$http_host_suffix$proxy_uri; } # 匹配所有請求,進行 URL 重寫、訪問控制、轉發請求以及響應處理(各階段的處理在此配置)。 location / { set $context ''; ... rewrite_by_lua_file lua/rewrite_by_lua.lua; access_by_lua_file lua/access_by_lua.lua; content_by_lua_file lua/content_by_lua.lua; } }}
- D. URL 規范
- 內網服務遵循的 URL 格式為 http://<host>:<port>/<context>/path/to/your/api,應用上下文根緊跟在 <host>:<port> 之后,以便統一獲取來找到配置。比如:http://172.0.8.177:8080/user/users/{uid}/info,其中 user 為應用上下文根,緊跟在 172.0.8.177:8080 之后。
- E. 樣例
- 內網用戶信息服務由原來的 API:/user/users/{uid}/info 提供,后來遷移至 API:/user/users/{uid}/user-info,當第三方 CampA (IP 為 172.0.1.172) 發起 GET 請求時,請求 URL 為 http://openapi.company.com/user/users/27/info?thirdparty=CampA&cp=fj375x...sign=abxuos8nb...。
- 初始化常量和依賴等
- 通過 CampA 與 user Context 獲取第三方配置
- HAProxy Internet 接收請求發到 OpenApi 接口網關,OpenApi 把 /user/users/27/info URI 重寫為 /user/users/27/user-info/
- 校驗第三方請求 IP,在 IP 白名單中,校驗通過;校驗 URI /user/users/27/user-info 在授權的 URI 中,校驗通過
- 調用安全服務對請求進行解密與驗簽,解密成功,驗簽通過,獲取明文
- 將擁有明文的請求轉發到開放接口服務
- 獲取響應,調用安全服務對響應報文進行簽名與加密,返回給第三方 CampA。
- 3.2.2.2 配置服務設計

- A. 數據庫表設計
- openapi_thirdparty_config

- openapi_api_config

- openapi_api_mapping

- B. 配置服務接口響應
{ # 接口映射配置 "apiMapping":{ "$context":{ "$fromApi":"$toApi" } }, # 接口白名單配置、加解密配置 "apiConfig":{ "$channel $context":{ "$httpMethod $uri":{ "reqNeedDecrypt":false, "respNeedEncrypt":false } } }, # IP 白名單配置,驗簽名配置 "channelConfig":{ "$channel":{ ips:{ "$ip":1 }, "reqNeedVerifySign":false, "respNeedSign":false, "needCheckIp":false } }}
- 3.2.2.3 安全服務設計
- 為了保證請求或響應的完整性、以及請求或響應來源的合法性,雙方傳輸需要進行簽名;另外,由于可能開放接口的請求或響應會包含敏感信息,需要進行加密傳輸。這里的安全服務就是指請求的解密與驗簽和響應的簽名與加密服務。

- A. 算法約定
- 對稱加密算法:3DES(DESede/ECB/PKCS5Padding)
- 非對稱加密算法:RSA(RSA/ECB/PKCS1Padding)
- 簽名算法:SHA1WithRSA
- B. 公鑰約定
- 雙方預先交換 RSA 公鑰
- 雙方公鑰編碼方式:UTF-8 編碼的 Base64String
- 雙方進行加解密與驗簽名可使用同一把 RSA 公私鑰或者分別使用各自的公私鑰,雙方約定即可
- C. 第三方請求流程示意

- 其中,添加統一參數為必選步驟,請求簽名、請求加密、響應解密、以及響應驗簽都是可選步驟。
- 無論是 GET、POST 或者其他方式的請求,第三方在訪問平臺開放接口前,都需要添加統一參數到 request parameter 中
- 統一參數包括第三方應用名、請求時間戳、隨機不重復字符串 nonce 等
- 驗簽名屬于應用維度 —— 針對應用做驗簽名(比如:按照約定需要對第三方應用 A 進行驗簽,則應用 A 訪問數禾任何接口都需要簽名)
- 加解密屬于接口維度 —— 針對接口做加解密(比如:同一個第三方訪問 A 接口需要加密,而訪問 B 接口可以不需加密)
- D. 加解密示意(以第三方請求為例)

- E. 驗簽名示意(以第三方請求為例)

- 3.2.3 架構總結
- 由于 Nginx 與 Lua 本身杰出的性能,在當前的系統量級與整體 IT 架構下,我們使用這樣的接口網關架構已經可以支撐較大的并發請求。在最后的這一節,我們不妨回顧一下前文講述的接口網關架構,看看目前性能上仍存在著的兩個主要待改進的地方。
- 兩層 HAProxy 代理:在使用更優產品替代 Openshift 架構的情況下,直接部署接口網關到公網,可消除外層 HAProxy 代理;在實現服務治理的情況下,由接口網關直接轉發請求到服務,可消除內層 HAProxy 代理。
- 安全服務性能:加解密驗簽名等安全服務是以內部服務的方式提供給接口網關,而且使用了性能不太好的 ngx.location.capture 轉發原請求,在系統量級增大后會遇到性能瓶頸,可通過使用高性能的 Lua 腳本在接口網關層提供安全服務,從而提升安全服務性能。
- 除了以上主要的兩點,隨著系統量級的提升與整體 IT 架構的演進,接口網關的架構也會隨之調整和演進,在各個方面都盡可能地優化性能,以適應更大系統量級的需求。