精心設計的 DNS Failover 策略在 Go 中竟然帶來了反效果,發生了什麼?
本期作者
衛智雄
嗶哩嗶哩高級運維工程師
一. 背景
如下配置所示,我們在 /etc/resolv.conf 中配置了兩個 nameserver,其中 server2 在災備機房 ,作爲一種 failover 策略。
nameserver server1
nameserver server2
options timeout:1 attempts:1
我們的預期是如果 server1 服務正常,則所有的 DNS 請求應該由 server1 處理,且 server2 故障不應對業務有任何影響 。只有當 server1 服務異常,DNS 請求才應該重試到 server2。
然而我們在線上觀察到一直有 AAAA 類型的 DNS 請求發送到 server2,而且如果 client 到 server2 的網絡異常時,業務的 http 請求耗時會增加 1s,這並不符合預期。同時因爲我們的內網域名都沒有 AAAA 記錄,且內網服務器也是關閉了 IPv6 協議的,AAAA 請求也不符合預期。
二. 問題排查
經過和業務同學求證,相關程序語言爲 Go ,請求使用的是 Go 原生 net 庫。在 Go net 庫中,最經常使用的方式如下:
package main
import (
"net"
"net/http"
)
func main() {
http.Get("https://internal.domain.name")
net.Dial("tcp", "internal.domain.name:443")
}
1. 梳理源碼
讓我們順着源碼分析 net 庫的解析邏輯。無論是 http.Get 還是 net.Dial 最終都會到 func (d *Dialer) DialContext() 這個方法。然後層層調用到 func (r *Resolver) lookupIP() 方法,這裏定義了何時使用 Go 內置解析器或調用操作系統 C lib 庫提供的解析方法,以及 /etc/hosts 的優先級。
同時補充一個比較重要的信息:windows 、darwin(MacOS 等) 優先使用 C lib 庫解析,debug 時需要注意。
func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {
...
addrs, err := d.resolver().resolveAddrList(resolveCtx, "dial", network, address, d.LocalAddr)
...
}
func (r *Resolver) resolveAddrList(ctx context.Context, op, network, addr string, hint Addr) (addrList, error) {
...
addrs, err := r.internetAddrList(ctx, afnet, addr)
...
}
func (r *Resolver) internetAddrList(ctx context.Context, net, addr string) (addrList, error) {
...
ips, err := r.lookupIPAddr(ctx, net, host)
...
}
func (r *Resolver) lookupIPAddr(ctx context.Context, network, host string) ([]IPAddr, error) {
...
resolverFunc := r.lookupIP
...
ch := r.getLookupGroup().DoChan(lookupKey, func() (any, error) {
return testHookLookupIP(lookupGroupCtx, resolverFunc, network, host)
})
...
}
func (r *Resolver) lookupIP(ctx context.Context, network, host string) (addrs []IPAddr, err error) {
if r.preferGo() {
return r.goLookupIP(ctx, network, host)
}
order, conf := systemConf().hostLookupOrder(r, host)
if order == hostLookupCgo {
return cgoLookupIP(ctx, network, host)
}
ips, _, err := r.goLookupIPCNAMEOrder(ctx, network, host, order, conf)
return ips, err
}
我們線上的操作系統是 Debain,確認會使用 Go 內置解析器。所以下一步來到了 func (r *Resolver) goLookupIPCNAMEOrder() 方法。這裏我們可以通過 qtypes 看到如果 net.Dial 的 network 參數傳入的是 tcp ,域名的 A 和 AAAA 記錄都會被查詢,無論服務器是否關閉 ipv6。
func (r *Resolver) goLookupIPCNAMEOrder(ctx context.Context, network, name string, order hostLookupOrder, conf *dnsConfig) (addrs []IPAddr, cname dnsmessage.Name, err error) {
...
lane := make(chan result, 1)
qtypes := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
switch ipVersion(network) {
case '4':
qtypes = []dnsmessage.Type{dnsmessage.TypeA}
case '6':
qtypes = []dnsmessage.Type{dnsmessage.TypeAAAA}
}
var queryFn func(fqdn string, qtype dnsmessage.Type)
var responseFn func(fqdn string, qtype dnsmessage.Type) result
if conf.singleRequest {
queryFn = func(fqdn string, qtype dnsmessage.Type) {}
responseFn = func(fqdn string, qtype dnsmessage.Type) result {
dnsWaitGroup.Add(1)
defer dnsWaitGroup.Done()
p, server, err := r.tryOneName(ctx, conf, fqdn, qtype)
return result{p, server, err}
}
} else {
queryFn = func(fqdn string, qtype dnsmessage.Type) {
dnsWaitGroup.Add(1)
go func(qtype dnsmessage.Type) {
p, server, err := r.tryOneName(ctx, conf, fqdn, qtype)
lane <- result{p, server, err}
dnsWaitGroup.Done()
}(qtype)
}
responseFn = func(fqdn string, qtype dnsmessage.Type) result {
return <-lane
}
}
for _, fqdn := range conf.nameList(name) {
for _, qtype := range qtypes {
queryFn(fqdn, qtype)
}
}
...
for _, qtype := range qtypes {
result := responseFn(fqdn, qtype)
}
...
}
從 goLookupIPCNAMEOrder 方法中我們可以看到由 tryOneName 方法分別處理 A 和 AAAA 記錄。深入 tryOneName 內部,我們終於發現具體的 nameserver 選擇邏輯,在某些錯誤情況下會重試請求到下一個 nameserver。
func (r *Resolver) tryOneName(ctx context.Context, cfg *dnsConfig, name string, qtype dnsmessage.Type) (dnsmessage.Parser, string, error) {
...
q := dnsmessage.Question{
Name: n,
Type: qtype,
Class: dnsmessage.ClassINET,
}
for i := 0; i < cfg.attempts; i++ {
for j := uint32(0); j < sLen; j++ {
server := cfg.servers[(serverOffset+j)%sLen]
p, h, err := r.exchange(ctx, server, q, cfg.timeout, cfg.useTCP, cfg.trustAD)
...
if err := checkHeader(&p, h); err != nil {
dnsErr := &DNSError{
Err: err.Error(),
Name: name,
Server: server,
}
if err == errServerTemporarilyMisbehaving {
dnsErr.IsTemporary = true
}
if err == errNoSuchHost {
// The name does not exist, so trying
// another server won't help.
dnsErr.IsNotFound = true
return p, server, dnsErr
}
lastErr = dnsErr
continue
}
...
}
** 2. 線上 debug**
接下來我們可以構造一個簡單的程序在線上 debug,看看到底是因爲原因導致 AAAA 請求重試到了下一個 nameserver。(tips: debug 需要把 resolv.conf 的 timeout 調長一些)
package main
import (
"net"
)
func main() {
c, err := net.Dial("tcp", "internal.domain.name:80")
if err != nil {
return
}
_ = c.Close()
}
dlv debug main.go
(dlv) break /usr/local/go/src/net/dnsclient_unix.go:279
(dlv) break /usr/local/go/src/net/dnsclient_unix.go:297
(dlv) continue
(dlv) print err
error(*errors.errorString) *{
s: "lame referral",}
通過 debug 我們最終定位到 err 由下面這段代碼拋出。
func checkHeader(p *dnsmessage.Parser, h dnsmessage.Header) error {
...
// libresolv continues to the next server when it receives
// an invalid referral response. See golang.org/issue/15434.
if h.RCode == dnsmessage.RCodeSuccess && !h.Authoritative && !h.RecursionAvailable && err == dnsmessage.ErrSectionDone {
return errLameReferral
}
....
}
原來如果返回的 DNS response 以下 4 個條件全部滿足,就會觸發重試邏輯:
-
響應沒有錯誤
-
應答 Server 不是權威服務器
-
應答 Server 不支持遞歸請求
-
應答的 records 爲空
這裏有一個疑點是我們的 DNS Server 是支持遞歸請求的,經過排查,我們發現是因爲在 DNS Server 有一層 NetScaler 作爲負載均衡器,負載均衡是以 DNS proxy server 的方式運行,默認並沒有開啓對遞歸請求的支持。
我們可以運行 dig 命令觀察是否有如下輸出來判斷 server 是否支持遞歸請求。
;; WARNING: recursion requested but not available
3. 原因梳理
至此,我們已經弄清楚了爲什麼會有 AAAA 類型的請求發送到 nameserver2。而文章開頭提到的業務 http 請求耗時增加 1s 的原因則是因爲 client 到 server2 網絡異常時,需要等待重試的 AAAA 請求超時,纔會返回解析結果。
還有一個問題困擾着我們,爲什麼用 ping 等程序驗證,並沒有發現類似的問題。我們通過直接用 C getaddrinfo 函數測試,以及通過 -tags 'netcgo' 編譯相同的 go 程序驗證,發現在 A 記錄有值的情況下,AAAA 請求都不會重試到下一個 nameserver。回到 Go 中觸發重試的這段代碼深入分析,註釋中可以看到由 golang.org/issue/15434 引入,提交代碼的作者是爲了解決 issue 中的問題複製了 libresolv 的行爲。然而翻閱 glibc 的代碼可以看到 next_ns 中還有這樣一段邏輯:只要 A 或者 AAAA 任意一個有記錄值,都不會重試到下一個 nameserver。這段邏輯並沒有引入 Go 中。所以我們需要注意 Go 內置解析器與 glibc 中的行爲和結果都有差異,它可能會影響到我們的服務。
next_ns:
if (recvresp1 || (buf2 != NULL && recvresp2)) {
*resplen2 = 0;
return resplen;
}
...
if (anhp->rcode == NOERROR && anhp->ancount == 0
&& anhp->aa == 0 && anhp->ra == 0 && anhp->arcount == 0) {
goto next_ns;
}
三. 優化
經過上面的排查,我們已經確認了 AAAA 請求的源頭,以及爲什麼會重試到下一個 server。接下來可以針對性的優化。
1. 對於 Go 程序中 AAAA 請求重試到下一個 server 的優化方案:
a. 代價相對較小的方案,程序構建時添加 -tags 'netcgo' 編譯參數,指定使用 cgo-based 解析器。
b. DNS Server proxy 層支持遞歸請求。這裏有必要說明遞歸支持不能在 proxy 層簡單的直接開啓,proxy 和 recursion 在邏輯上有衝突的地方,務必做好必要的驗證和確認,否則可能會帶來新的問題。
- 如果業務程序不需要支持 IPv6 網絡,可以通過指定網絡類型爲 IPv4,來消除 AAAA 請求,同時避免隨之帶來的問題。(也順帶減少了相關開銷)
a. net.Dial 相關方法可以指定 network 爲 tcp4、udp4 來強制使用 IPv4
net.Dial("tcp4", "internal.domain.name:443")
net.Dial("udp4", "internal.domain.name:443")
b. net/http 相關方法可以通過如下示例來強制使用 IPv4
package main
import (
"context"
"log"
"net"
"net/http"
"time"
)
func main() {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
httpClient.Transport = transport
resp, err := httpClient.Get("https://internal.domain.name")
if err != nil {
log.Fatal(err)
}
log.Println(resp.StatusCode)
}
四. 總結
-
Go net 庫中提供了兩種解析邏輯:自實現的內置解析器和系統提供的解析函數。windows 、darwin(MacOS 等) 優先使用系統提供的解析函數,常見的 Debain、Centos 等優先使用內置解析器。
-
Go net 庫中的內置解析器和系統提供的解析函數行爲和結果並不完全一致,它可能會影響到我們的服務。
-
業務應設置合理的超時時間,不易過短,以確保基礎設施的 failover 策略有足夠的響應時間。
推薦閱讀:
https://studygolang.com/topics/15021
https://pkg.go.dev/net 中的 Name Resolution 章節
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/BqNLR9rsWSPtTJpzOX1uOw