改造LVS支持TOA

在四層負載均衡平臺,我們使用LVS來實現(xiàn)核心功能阱驾。由于公司使用的linux大多數(shù)的內(nèi)核是4.9.0.XXX泊业,該版本只支持NAT,不能支持fullnat啊易,fullnat是阿里在多年前的2.6.32版本的內(nèi)核上開發(fā)支持了fullnat吁伺,并開源出來,阿里開源的這個該版本不僅支持了fullnat租谈,而且還支持TOA功能篮奄,可以把客戶端的IP和Port帶到RealServer捆愁,但是該版本已經(jīng)停止維護。

目的

現(xiàn)在微服務化已經(jīng)應用非常廣泛窟却,微服務最常見的就是實現(xiàn)服務的高可用和負載均衡性昼丑,那么LVS就是其中一個選項,我們提供的四層負載均衡平臺就是基于LVS來實現(xiàn)的一套負載均衡平臺夸赫。

在很多業(yè)務場景下菩帝, 我們在對外提供服務時,需要檢查服務請求方的IP地址茬腿,來針對IP地址做一些業(yè)務處理呼奢,最常見的一個例子就是:做白名單校驗,只有在白名單列表中的IP地址切平,我們才允許它訪問我們的服務握础;還有一種應用場景,那就是基于客戶端的請求IP來進行調(diào)度悴品,譬如CDN服務禀综,那么就需要根據(jù)客戶端的請求IP,來調(diào)度最近最適合的資源提供服務苔严。

LVS支持的三種模式:Tunnel定枷、DR和NAT,其中Tunnel届氢,DR這兩種模式都能夠把客戶端IP透傳到RealServer上依鸥,但是由于在NAT模式下,由于我們的四層服務均衡平臺是由于實現(xiàn)了FULLNAT悼沈,所以最終用戶請求到業(yè)務服務器時贱迟,業(yè)務服務中拿到的是LVS的主機IP地址,而不是請求方的IP地址絮供,這樣衣吠,業(yè)務服務就無法針對請求IP來做業(yè)務邏輯處理了。

阿里提供的fullnat版本的內(nèi)核中壤靶,實現(xiàn)了TOA的功能缚俏,可以在fullnat的場景下,把客戶端ip地址傳到RealServer中贮乳。本文旨在講解忧换,在LVS的NAT模式下,我們是如何實現(xiàn)TOA的功能向拆,以及在實現(xiàn)的過程中亚茬,踩到的一些坑。

要實現(xiàn)TOA功能浓恳,設計到兩塊的改造:

    1. 改造LVS刹缝,在NAT模式下碗暗,實現(xiàn)TOA的功能;
    1. 在所有的RealServer中梢夯,安裝TOA插件言疗;

TOA的插件可以參考:https://github.com/Huawei/TCP_option_address

實現(xiàn)對TOA的支持

如果不做改造颂砸,基于LVS的NAT實現(xiàn)的包轉(zhuǎn)發(fā)如下圖所示:

image.png

假如我們配置了一個NAT的負載均衡噪奄,當圖中一個請求達到LVS服務時,會進行如下幾個步驟:

  • 進入時人乓,源IP:10.1.1.5勤篮,目標IP:10.1.1.1
  • 進入LVS的DNAT邏輯中,做DNAT處理后撒蟀,源IP:10.1.1.5,目標IP:192.168.1.2
  • 包出主機時温鸽,由iptables做snat,變成源IP:192.168.1.1,目標IP:192.168.1.2
  • RS收到包飞蛹,源IP:192.168.1.1清焕,目標IP:192.168.1.2
  • RS處理處理后回包,源IP:192.168.1.2蝠猬,目標IP:192.168.1.1
  • 回包進入LVS服務切蟋,源IP:192.168.1.2,目標IP:192.168.1.1
  • 主機層面iptables榆芦,做dnat柄粹,變成源IP:192.168.1.2,目標IP:10.1.1.5
  • LVS服務做snat處理匆绣,變成源IP:10.1.1.1驻右,目標IP:10.1.1.5

那么,可以看到在RS上拿到的源ip地址是192.168.1.1崎淳,是LVS的內(nèi)網(wǎng)地址堪夭,不是客戶端ip。

LVS實現(xiàn)對TOA的支持

阿里開源的LVS的版本內(nèi)核是2.6.32拣凹,在該版本中森爽,實現(xiàn)了TOA功能的支持,主要原理是在4層TCP協(xié)議的options字段名中增加源IP和PORT信息嚣镜。TOA OPTION的OPCODE為254(0xfe), 長度為8字節(jié)爬迟,結(jié)構(gòu)為:

struct toadata
{
    __u8   opcode;
    __u8   opsize;
    __u16  port;
    __u32  ip;
}

TOA實現(xiàn)的原理圖為:


image.png

由于公司內(nèi)部試用的內(nèi)核是4.9以上,所以改造了一下LVS的代碼菊匿,在NAT的基礎上對toa實現(xiàn)了支持雕旨,筆者在dnat的處理邏輯代碼上進行改造扮匠,按需把TOA options插入到tcp協(xié)議包中。

在RS上部署TOA插件解析客戶端IP

為了讓RS解析到真實客戶端IP和PORT凡涩,還需要在RS服務器上安裝TOA插件棒搜,

insmod toa.ko

在TOA插件插件中,HOOK內(nèi)核的兩個方法tcp_v4_sync_recv_sock_toa和inet_getname_toa活箕。

其中tcp_v4_sync_recv_sock_toa的邏輯是判斷TCP Options中是否存在TOA選項力麸,如果有,就把TOA中的ip和端口的數(shù)據(jù)讀取出來育韩,并存放到sock結(jié)構(gòu)體的sk_user_data指針中克蚂。

注:由于TOA選項占用8個字節(jié),它利用了sk_user_data指針在64位主機筋讨,也是占用8個字節(jié)埃叭,在32位機器上,就不能正常工作了悉罕。

而inet_getname_toa在應用層調(diào)用getpeername或者getsocketname是調(diào)用赤屋,在該函數(shù)中,會判斷sock結(jié)構(gòu)體中的sk_user_data指針是否為不為空壁袄,如果不為空类早,就按照結(jié)構(gòu)體TOA讀取出真正的客戶端ip和端口號。

用nginx部署RS驗證

按照前文中nginx的部署嗜逻,配置好lvs的環(huán)境涩僻。
在客戶端訪問lvs的服務:

curl http://10.1.1.1
hello world

當沒安裝TOA插件時,我們查看日志文件/data/weblog/nginx/_.access.log中的內(nèi)容為:

192.168.1.1 - - - [09/Sep/2019:08:04:41 +0800] "GET / HTTP/1.1" 200 22 "-" "curl/7.47.0" "0.000" "-"

這里可以看到日志中記錄的客戶端ip地址為192.168.1.1栈顷,是LVS服務器的內(nèi)網(wǎng)地址逆日,在RS服務器(192.168.1.2)上安裝TOA插件后,再測試萄凤,發(fā)現(xiàn)日志變成了

10.1.1.5 - - - [09/Sep/2019:08:04:41 +0800] "GET / HTTP/1.1" 200 22 "-" "curl/7.47.0" "0.000" "-"

其中的客戶端IP地址已經(jīng)換成了真是的請求服務器ip地址屏富。

碰到的問題

通過用nginx驗證通過了以后,筆者這里就在線上把環(huán)境部署好了蛙卤,讓業(yè)務部門的兄弟去測試狠半,結(jié)果讓人大失所望,對方反饋說仍然拿不到客戶端ip颤难。
首先我需要重現(xiàn)對方的問題神年,再次用nginx做測試后,也是能夠正常工作的行嗤。

  • 我發(fā)現(xiàn)業(yè)務方的程序是用go語言編寫的
    于是自己寫了一個go語言的簡單的服務器代碼進行測試已日,一經(jīng)測試果然不行,代碼如下:
package main

import (
    "fmt"
    "net"
    "net/http"
)

func main() {
    http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
           fmt.Fprintf(w, "client address:%s栅屏,\n", r.RemoteAddr)
    })
    http.ListenAndServe("0.0.0.0:8080", nil)
}
  • 然后編寫了一個c語言的服務飘千,測試是能夠拿到客戶端ip

c語言的代碼不是http協(xié)議的堂鲜,是一個普通的tcp服務器,接受到客戶端連接后护奈,打印客戶端ip地址缔莲,代碼如下所示:

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/**
 * TCP Uses 2 types of sockets, the connection socket and the listen socket.
 * The Goal is to separate the connection phase from the data exchange phase.
 * */

int main(int argc, char *argv[]) {
    // port to start the server on
    int SERVER_PORT = 8080;

    // socket address used for the server
    struct sockaddr_in server_address;
    memset(&server_address, 0, sizeof(server_address));
    server_address.sin_family = AF_INET;

    // htons: host to network short: transforms a value in host byte
    // ordering format to a short value in network byte ordering format
    server_address.sin_port = htons(SERVER_PORT);

    // htonl: host to network long: same as htons but to long
    server_address.sin_addr.s_addr = htonl(INADDR_ANY);

    // create a TCP socket, creation returns -1 on failure
    int listen_sock;
    if ((listen_sock = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
        printf("could not create listen socket\n");
        return 1;
    }

    // bind it to listen to the incoming connections on the created server
    // address, will return -1 on error
    if ((bind(listen_sock, (struct sockaddr *)&server_address,
              sizeof(server_address))) < 0) {
        printf("could not bind socket\n");
        return 1;
    }

    int wait_size = 16;  // maximum number of waiting clients, after which
                         // dropping begins
    if (listen(listen_sock, wait_size) < 0) {
        printf("could not open socket for listening\n");
        return 1;
    }

    // socket address used to store client address
    struct sockaddr_in client_address;
    int client_address_len = 0;

    // run indefinitely
    while (true) {
        // open a new socket to transmit data per connection
        int sock;
        if ((sock =
                 accept(listen_sock, (struct sockaddr *)&client_address,
                        &client_address_len)) < 0) {
            printf("could not open a socket to accept data\n");
            return 1;
        }

        int n = 0;
        int len = 0, maxlen = 100;
        char buffer[maxlen];
        char *pbuffer = buffer;

        printf("client connected with ip address: %s\n", inet_ntoa(client_address.sin_addr));

        // keep running as long as the client keeps the connection open
        while ((n = recv(sock, pbuffer, maxlen, 0)) > 0) {
            pbuffer += n;
            maxlen -= n;
            len += n;

            printf("received: '%s'\n", buffer);

            // echo received content back
            send(sock, buffer, len, 0);
        }

        close(sock);
    }

    close(listen_sock);
    return 0;
}

分析和定位問題

從上面的測試可以看出c語言的后端,很明面霉旗,我們在toa模塊中hook的鉤子函數(shù)被調(diào)用了痴奏,而在與go語言的后端程序建立tcp連接的時候,鉤子函數(shù)沒有被調(diào)用厌秒。
筆者之前沒有接觸過內(nèi)核读拆,對tcp的內(nèi)核里面的調(diào)用邏輯這塊有點生疏,所以初步懷疑是不是go語言做了特殊處理鸵闪,內(nèi)核的處理邏輯跟c語言的調(diào)用路線不一致呢檐晕?答案顯然是不可能的。

要解決問題蚌讼,還是需要從內(nèi)核入手辟灰,于是拿到一套公司用到的內(nèi)核的源碼,來查看tcp連接建立過程中的代碼的調(diào)用關系啦逆,初步理清伞矩,一個連接建立三次握手最后的ACK消息處理的簡單的調(diào)用關系為:tcp_v4_rcv->tcp_check_req->tcp_v4_syn_recv_sock笛洛。

在TOA中的代碼中夏志,hook了tcp_v4_syn_recv_sock函數(shù),在tcp_v4_syn_recv_sock_toa函數(shù)中去讀取TOA選項中的數(shù)據(jù)來獲取客戶端的真實ip信息苛让。

調(diào)試過程

    1. 在tcp_v4_sync_recv_sock函數(shù)中加了一行調(diào)試日志沟蔑,重新編譯內(nèi)核后,再次發(fā)起tcp到go語言的lvs服務的調(diào)用狱杰,發(fā)現(xiàn)tcp_v4_sync_recv_sock_toa沒被調(diào)用瘦材,但是tcp_v4_sync_recv_sock被調(diào)用了;
    1. 打開文件net/ipv4/tcp_minisocks.c文件仿畸,修改tcp_check_req方法食棕,在其中加一行調(diào)試日志
printk("tcp_check_req call ops:%p, syn_recv_sock: %p\n", inet_csk(sk)->icsk_af_ops,  inet_csk(sk)->icsk_af_ops->syn_recv_sock) ;

通過打印發(fā)現(xiàn),在tcp_check_req方法中错沽,打印的sync_recv_sock的地址不是tcp_v4_sync_recv_sock_toa的地址簿晓,也不是tcp_v4_sync_recv_sock的地址。

    1. 懷疑是tcp_v6_syn_recv_sock被調(diào)用了千埃,打開net/ipv6/tcp_ipv6.c憔儿,在tcp_v6_syn_recv_sock加上日志,發(fā)現(xiàn)果然在這里被調(diào)用了放可。

到這里基本上知道問題所在了谒臼,普通的c程序偵聽時是基于tcp4協(xié)議朝刊,而go語言是基于tcp6的。
從go的Listen函數(shù)文檔中截取出來的說明:

func Listen(network, address string) (Listener, error)
Listen announces on the local network address.
The network must be "tcp", "tcp4", "tcp6", "unix" or "unixpacket".
For TCP networks, if the host in the address parameter is empty or a literal unspecified IP address, Listen listens on all available unicast and anycast IP addresses of the local system. To only use IPv4, use network "tcp4". The address can use a host name, but this is not recommended, because it will create a listener for at most one of the host's IP addresses. If the port in the address parameter is empty or "0", as in "127.0.0.1:" or "[::1]:0", a port number is automatically chosen. The Addr method of Listener can be used to discover the chosen port.
See func Dial for a description of the network and address parameters.

在主機上查看

# netstat -tnpl | grep go-server
tcp6       0      0 :::8080                 :::*                    LISTEN      4391/go-server

果然蜈缤,由于go語言程序缺省采用tcp6網(wǎng)絡偵聽拾氓,我們需要hook tcp6的函數(shù)才行。

問題的解決方案

第一種解決方案:hook tcp6的函數(shù)

TOA的代碼中劫樟,有一個宏定義CONFIG_IP_VS_TOA_IPV6痪枫,來確定TOA模塊是否會hook ipv6相關的函數(shù),但是我打開宏之后叠艳,編譯不過奶陈。

在內(nèi)核代碼中,打開net/ipv6/tcp_ipv6.c中查看代碼發(fā)現(xiàn):

static const struct inet_connection_sock_af_ops ipv6_specific;
......
static struct sock *tcp_v6_syn_recv_sock(const struct sock *sk, struct sk_buff *skb,
                     struct request_sock *req,
                     struct dst_entry *dst,
                     struct request_sock *req_unhash,
                     bool *own_req)

變量ipv6_specific和函數(shù)tcp_v6_syn_recv_sock都是static定義的附较,不會對外宣告吃粒,頭文件中也沒有相關信息,所以要解決這個問題拒课,需要把這幾個變量添加到頭文件中徐勃,并且把static去掉,并且對外export變量早像。
重新編譯內(nèi)核和toa模塊僻肖,即可。

第二種解決方案:改造go程序代碼卢鹦,只偵聽tcp4網(wǎng)絡

改造后的go語言代碼如下所示:

package main

import (
    "fmt"
    "net"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    http.HandleFunc("/", func (w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "client address:%s臀脏,\n", r.RemoteAddr)
    })
    server := &http.Server{Handler: mux}
    l, _ := net.Listen("tcp4", "0.0.0.0:8080")
    server.Serve(l)
}

總結(jié)

上面提到的兩種解決方案中,我們目前暫時采用的第二種冀自,業(yè)務部門改寫代碼偵聽tcp4網(wǎng)絡后揉稚,能夠正常工作了。第一種方案改造比較徹底熬粗,因為涉及到所有機器的內(nèi)核的升級搀玖,工作量比較大。
工信部近期正在推ipv6驻呐,四層負載均衡面臨著對ipv6提供支持灌诅,前面的TOA結(jié)構(gòu)體中存儲的數(shù)據(jù)需要做相應的修改。所以考慮在后續(xù)的版本開發(fā)中含末,統(tǒng)一對第一種方案進行支持改造猜拾。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市答渔,隨后出現(xiàn)的幾起案子关带,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件宋雏,死亡現(xiàn)場離奇詭異芜飘,居然都是意外死亡,警方通過查閱死者的電腦和手機磨总,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進店門嗦明,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人蚪燕,你說我怎么就攤上這事娶牌。” “怎么了馆纳?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵诗良,是天一觀的道長。 經(jīng)常有香客問我鲁驶,道長鉴裹,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任钥弯,我火速辦了婚禮径荔,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘脆霎。我一直安慰自己总处,他們只是感情好,可當我...
    茶點故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布睛蛛。 她就那樣靜靜地躺著鹦马,像睡著了一般。 火紅的嫁衣襯著肌膚如雪玖院。 梳的紋絲不亂的頭發(fā)上菠红,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天第岖,我揣著相機與錄音难菌,去河邊找鬼。 笑死蔑滓,一個胖子當著我的面吹牛郊酒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播键袱,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼燎窘,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了蹄咖?” 一聲冷哼從身側(cè)響起褐健,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后蚜迅,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體舵匾,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年谁不,在試婚紗的時候發(fā)現(xiàn)自己被綠了坐梯。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡刹帕,死狀恐怖吵血,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情偷溺,我是刑警寧澤蹋辅,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站挫掏,受9級特大地震影響晕翠,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜砍濒,卻給世界環(huán)境...
    茶點故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一淋肾、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧爸邢,春花似錦樊卓、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至券敌,卻和暖如春唾戚,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背待诅。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工叹坦, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人卑雁。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓募书,卻偏偏與公主長得像,于是被迫代替她去往敵國和親测蹲。 傳聞我的和親對象是個殘疾皇子莹捡,可洞房花燭夜當晚...
    茶點故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容