DNS 做服務發現,是坑嗎 ?

背景分享

遇到過這麼一個問題,有童鞋的 Go 程序用 DNS 解析做服務發現(內網用的 CoreDNS 做的域名解析服務器)。比如,內網有個服務域名,對應 7 個後端節點。爲了做服務發現,故障的剔除等服務,在 Client 端對一個給定的域名調用 Go 標準庫的  Resolver.LookupHost  方法來解析 ip 列表。如果解析得到的 ip 列表有變化,那麼在 Client 內對相應的對後端節點的鏈接做創建和銷燬。

addrs, err := resolver.LookupHost(ctx, /*某服務域名 A */)

// addrs 的結果會變化,一會返回 6 個 ip,一會返回 7 個 ip

就是這麼一個典型的服務發現的應用場景,還是精準踩坑。那什麼坑?

坑就是:解析得到的 ip 列表反覆變化,導致反覆創、刪連接和對應的結構體。讓人誤以爲 DNS 的後端節點一直在故障,從而導致一系列的問題。

還遇到另一個有趣的問題:同一份業務代碼,Go 1.15 編譯的版本總會頻繁截斷成 6 個 ip ,Go 1.16 以上的版本則非常穩定,一直返回 7 個 ip ? 這又是爲啥呢。

這個問題很簡單,但其實也很隱蔽。因爲很少人會這麼用,也很少人會注意到這個問題。

Go 的 DNS Lookup 的接口語義

先看下 Go 標準庫的接口語義,看下 Resolver.LookupHost 在 Go 的註釋怎麼說的。文件在 Go 的標準庫 net/lookup.go :

// LookupHost looks up the given host using the local resolver.
// It returns a slice of that host's addresses.
func (r *Resolver) LookupHost(ctx context.Context, host string) (addrs []string, err error) {
    //...
}

LookupHost 查詢一個給定的域名,返回值是一個地址列表。注意:它並沒有保證,要返回該域名的所有 ip 列表。 所以啊,這本來就是用法不對,Go 的接口沒聲明說要返回全部的 ip 。哪怕有域名對應有 100 個 ip ,這個接口只返回 1 個也是對的。

Go 1.15 和 Go 1.16 之上的區別 ?

域名對應 7 個 ip ,同一份解析代碼, Go 1.15 編譯的程序時而返回 6 個?但 Go 1.16 之上的版本編譯則總是 7 個,感覺非常穩定。爲什麼呢?

筆者還真翻了一下 Go 1.15 和 Go 1.16 的區別,DNS 解析的代碼幾乎一致,只在 dnsPacketRoundTrip 函數中,改了一個 buffer 的大小。

Go 1.15 是這樣的( 文件:src/net/dnsclient_unix.go ):

func dnsPacketRoundTrip(c Conn, id uint16, query dnsmessage.Question, b []byte) (dnsmessage.Parser, dnsmessage.Header, error) {
    // 發送請求
    if _, err := c.Write(b); err != nil {
    }

    // 創建一個裝響應包的 buffer 
    b = make([]byte, 512) // see RFC 1035
    for {
        // 讀取 dns 響應
        n, err := c.Read(b)
        // ...
        return p, h, nil
    }
}

Go 1.16 是這樣的( 文件:src/net/dnsclient_unix.go ):

const (
    // Maximum DNS packet size.
    // Value taken from https://dnsflagday.net/2020/.
    maxDNSPacketSize = 1232
)

func dnsPacketRoundTrip(c Conn, id uint16, query dnsmessage.Question, b []byte) (dnsmessage.Parser, dnsmessage.Header, error) {
    // 發送請求
    if _, err := c.Write(b); err != nil {
    }

    // 創建一個裝響應包的 buffer  
    b = make([]byte, maxDNSPacketSize)
    for {
        // 讀取 dns 響應
        n, err := c.Read(b)
        // ...
        return p, h, nil
    }
}

函數邏輯是發送請求給 DNS Server ,並等待它的響應。兩個版本完全一致,只有 buffer 的大小不一樣,Go 1.16 之上用了 1232 這個大小。請注意,這個大小其實是有講究的,這個值是在儘量避免 IP 包分片又能儘量多裝數據而拍的一個值。詳細看 DNS FLAG DAY 2020[1] 。

這就是 Go 1.15 ,Go 1.16 版本在內網域名解析中的差異。DNS 服務端雖然發了 7 個 ip 過來,但是 Go 1.15 編譯的版本用 512 個字節 buffer 裝不下,只解析到 6 個有效 ip,Go 1.16 版本則好點,客戶端用的 1232 個字節的 buffer 大一點,差別就在這個地方。

這裏有個細節提一下:

DNS 的協議,Message 的 Header 有四個字段 QDCOUNT,ANCOUNT,NSCOUNT,ARCOUNT,是指明瞭數據包裏各個 Record 有多少個,Answer 有多少個的。但是在協議實現的時候,往往不依賴於這幾個字段,因爲它們可能被僞造攻擊。所以解析的 ip 列表都是按照實際解析結果來的,解析到多少個就多少個,而不是 Header 裏聲明瞭多少個。

// Qdcount, Ancount, Nscount, Arcount can't be trusted, as they are
// attacker controlled.

簡單說下 DNS

DNS 協議默認使用 UDP 協議作爲其傳輸層協議。UDP 的數據包是有限制的,DNS 的消息大小也是有限制的,基本大小限制爲 512 字節,長消息會被截斷並且設置標記位。

所以,DNS 協議本身就從來沒承諾過,給你解析完整的 IP 列表。它這個名字對應的 IP 而已,至於全不全,它從沒承諾過。

本文並不詳細介紹 DNS 協議的原理。詳細見 RFC 1035[2]  和相關的文檔。爲了突破數據包大小,或者其他的限制,也有擴展協議 EDNS ,可以參考 RFC 6891[3] 。

總之,用 DNS 解析 ip 這種方式來替代 consul 這種服務發現。感覺還是有欠缺的,或者說它的使用場景是不一樣的。

總結

參考資料

[1] DNS FLAG DAY 2020: https://dnsflagday.net/2020/

[2] RFC 1035: https://www.rfc-editor.org/rfc/rfc1035

[3] RFC 6891: https://www.rfc-editor.org/rfc/rfc6891

後記

本文分享的是 Go 編程一個非常小的知識點,Lookup 域名解析,你對它的期望是什麼?點贊、在看 是對奇伢最大的支持。

堅持思考,方向比努力更重要。關注我:奇伢雲存儲。歡迎加我好友,技術交流。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7ANAVBz6yWONWMptDskuag