文章首發:聊聊第一個開源項目 - CProxy 作者:會玩code
初衷
最近在學C++,想寫個項目練練手。對網絡比較感興趣,之前使用過ngrok(GO版本的內網穿透項目),看了部分源碼,想把自己的一些優化想法用C++實現一下,便有了這個項目。
項目介紹
CProxy是一個反向代理,用戶可在自己內網環境中啟動一個業務服務,並在同一網絡下啟動CProxyClient,用於向CProxyServer注冊服務。CProxyClient和CProxyServer之間會創建一個隧道,外網可以通過訪問CProxyServer,數據轉發到CProxyClient,從而被業務服務接收到。實現內網服務被外網訪問。
項目地址
https://github.com/lzs123/CProxy.git
使用方法
bash build.sh
// 啟動服務端
{ProjectDir}/build/server/Server --proxy_port=8090 --work_thread_nums=4
(另一個終端)
// 啟動客戶端
{ProjectDir}/build/client/Client --local_server=127.0.0.1:7777 --cproxy_server=127.0.0.1:8080
項目亮點
- 使用epoll作為IO多路復用的實現
- 數據轉發時,使用splice零拷貝,減少IO性能瓶頸
- 數據連接和控制連接接耦,避免互相影響
- 采用Reactor多線程模型,充分利用多核CPU性能
流程架構
角色
- LocalServer: 內網業務服務
- CProxyClient: CProxy客戶端,一般與LocalServer部署在一起,對接CProxyServer和InnerServer
- CProxyServer: CProxy服務端
- PublicClient: 業務客戶端
數據流
PublicClient先將請求打到CProxyServer,CProxyServer識別請求是屬於哪個CProxyClient,然后將數據轉發到CProxyClient,CProxyClient再識別請求是屬於哪個LocalServer的,將請求再轉發到LocalServer,完成數據的轉發。
工作流程
先介紹CProxyServer端的兩個概念:
- Control:在CProxyServer中會維護一個ControlMap,一個Control對應一個CProxyClient,存儲CProxyClient的一些元信息和控制信息
- Tunnel:每個Control中會維護一個TunnelMap,一個Tunnel對應一個LocalServer服務
在CProxyClient端,也會維護一個TunnelMap,每個Tunnel對應一個LocalServer服務,只不過Client端的Tunnel與Server端的Tunnel存儲的內容略有差異
啟動流程
CProxyServer
- 完成幾種工作線程的初始化。
- 監聽一個CtlPort,等待CProxyClient連接。
CProxyClient
- 完成對應線程的初始化。
- 然后連接Server的CtlPort,此連接稱為ctl_conn, 用於client和server之前控制信息的傳遞。
- 請求注冊Control,獲取ctl_id。
- 最后再根據Tunnel配置文件完成多個Tunnel的注冊。需要注意的是,每注冊一個Tunnel,Server端就會多監聽一個PublicPort,作為外部訪問LocalServer的入口。
數據轉發流程
- Web上的PublicClient請求CProxyServer上的PublicPort建立連接;CProxyServer接收連接請求,將public_accept_fd封裝成PublicConn。
- CProxyServer通過ctl_conn向client發送NotifyClientNeedProxyMsg通知Client需要創建一個proxy。
- Client收到后,會分別連接LocalServer和CProxyServer:
3.1. 連接LocalServer,將local_conn_fd封裝成LocalConn。
3.2. 連接ProxyServer的ProxyPort,將proxy_conn_fd封裝成ProxyConn,並將LocalConn和ProxyConn綁定。 - CProxyServer的ProxyPort收到請求后,將proxy_accept_fd封裝成ProxyConn,將ProxyConn與PublicConn綁定。
- 此后的數據在PublicConn、ProxyConn和LocalConn上完成轉發傳輸。
連接管理
復用proxy連接
為了避免頻繁創建銷毀proxy連接,在完成數據轉發后,會將proxyConn放到空閑隊列中,等待下次使用。
proxy_conn有兩種模式 - 數據傳輸模式和空閑模式。在數據傳輸模式中,proxy_conn不會去讀取解析緩沖區中的數據,只會把數據通過pipe管道轉發到local_conn; 空閑模式時,會讀取並解析緩沖區中的數據,此時的數據是一些控制信息,用於調整proxy_conn本身。
當有新publicClient連接時,會先從空閑列表中獲取可用的proxy_conn,此時proxy_conn處於空閑模式,CProxyServer端會通過proxy_conn向CProxyClient端發送StartProxyConnReqMsg,
CLient端收到后,會為這個proxy_conn綁定一個local_conn, 並將工作模式置為數據傳輸模式。之后數據在這對proxy_conn上進行轉發。
數據連接斷開處理
close和shutdown的區別
- close
int close(int sockfd)
在不考慮so_linger的情況下,close會關閉兩個方向的數據流。
- 讀方向上,內核會將套接字設置為不可讀,任何讀操作都會返回異常;
- 輸出方向上,內核會嘗試將發送緩沖區的數據發送給對端,之后發送fin包結束連接,這個過程中,往套接字寫入數據都會返回異常。
- 若對端還發送數據過來,會返回一個rst報文。
注意:套接字會維護一個計數,當有一個進程持有,計數加一,close調用時會檢查計數,只有當計數為0時,才會關閉連接,否則,只是將套接字的計數減一。
2. shutdownint shutdown(int sockfd, int howto)
shutdown顯得更加優雅,能控制只關閉連接的一個方向
howto = 0
關閉連接的讀方向,對該套接字進行讀操作直接返回EOF;將接收緩沖區中的數據丟棄,之后再有數據到達,會對數據進行ACK,然后悄悄丟棄。howto = 1
關閉連接的寫方向,會將發送緩沖區上的數據發送出去,然后發送fin包;應用程序對該套接字的寫入操作會返回異常(shutdown不會檢查套接字的計數情況,會直接關閉連接)howto = 2
0+1各操作一遍,關閉連接的兩個方向。
項目使用shutdown去處理數據連接的斷開,當CProxyServer收到publicClient的fin包(CProxyClient收到LocalServer的fin包)后,通過ctlConn通知對端,
對端收到后,調用shutdown(local_conn_fd/public_conn_fd, 2)關閉寫方向。等收到另一個方向的fin包后,將proxyConn置為空閑模式,並放回空閑隊列中。
在處理鏈接斷開和復用代理鏈接這塊遇到的坑比較多
- 控制對端去shutdown連接是通過ctl_conn去通知的,可能這一方向上對端的數據還沒有全部轉發完成就收到斷開通知了,需要確保數據全部轉發完才能調用shutdown去關閉連接。
- 從空閑列表中拿到一個proxy_conn后,需要發送StartProxyConnReq,告知對端開始工作,如果此時對端的這一proxy_conn還處於數據傳輸模式,就會報錯了。
數據傳輸
數據在Server和Client都需進行轉發,將數據從一個連接的接收緩沖區轉發到另一個連接的發送緩沖區。如果使用write/read系統調用,整個流程如下圖
數據先從內核空間復制到用戶空間,之后再調用write系統調用將數據復制到內核空間。每次系統調用,都需要切換CPU上下文,而且,兩次拷貝都需要CPU去執行(CPU copy),所以,大量的拷貝操作,會成為整個服務的性能瓶頸。
在CProxy中,使用splice的零拷貝方案,數據直接從內核空間的Source Socket Buffer轉移到Dest Socket Buffer,不需要任何CPU copy。
splice通過pipe管道“傳遞”數據,基本原理是通過pipe管道修改source socket buffer和dest socket buffer的物理內存頁
splice並不涉及數據的實際復制,只是修改了socket buffer的物理內存頁指針。
並發模型
CProxyClient和CProxyServer均采用多線程reactor模型,利用線程池提高並發度。並使用epoll作為IO多路復用的實現方式。每個線程都有一個事件循環(One loop per thread)。線程分多類,各自處理不同的連接讀寫。
CProxyServer端
為了避免業務連接處理影響到Client和Server之間控制信息的傳遞。我們將業務數據處理與控制數據處理解耦。在Server端中設置了三種線程:
- mainThread: 用於監聽ctl_conn和proxy_conn的連接請求以及ctl_conn上的相關讀寫
- publicListenThread: 監聽並接收外來連接
- eventLoopThreadPool: 線程池,用於處理public_conn和proxy_conn之間的數據交換。
CProxyClient端
client端比較簡單,只有兩種線程:
- mainThread: 用於處理ctl_conn的讀寫
- eventLoopThreadPool: 線程池,用於處理proxy_conn和local_conn之間的數據交換
遺留問題(未完待續。。)
在使用ab壓測時,在完成了幾百個轉發后,就卡住了,通過tcpdump抓包發現客戶端使用A端口連接,但服務端accept后打印的客戶端端口是B。
數據流在【publicClient->CProxyServer->CProxyClient->LocalServer】是正常的;
但回包方向【LocalServer->CProxyClient->CProxyServer-❌->publicClient】,目前還沒有找到分析方向。。。
寫在最后
喜歡本文的朋友,歡迎關注公眾號「會玩code」,專注大白話分享實用技術