踩了個DNS解析的坑,但我還是沒想通

捉蟲大師發表於2022-04-12

hello大家好,我是小樓。

最近踩了個DNS解析的小坑,雖然問題解決了,但排查過程比較曲折,最後還是有一點沒有想通,整個過程分享給大家。

背景

最近負責的服務要置換機器。置換機器可能很多小夥伴不知道是幹啥,因為大家平時接觸不到,我簡單解釋一下什麼是機器置換以及為什麼需要機器置換。

機器置換通俗地講就是更換機器,把服務從一臺機器遷移到另一臺上去。

為什麼要機器置換呢? 表面原因可能是機器硬體故障、或者機器過了保修期。

有些小夥伴可能就想問,我在公司也負責了很多服務,為啥從來沒有置換過機器呢?原因可能是用了容器,沒有直接部署在物理機上,置換機器的任務被轉移給了雲平臺的運維人員;還可能是你們有專門的運維幫忙做了這件事,對開發人員來說幾乎是透明的。

我負責的服務為啥要置換呢?因為機器過保了。服務為啥部署在物理機上呢?因為它是個基礎服務,和一般服務不太一樣,有一些限制,只能在物理機上部署。為啥沒有運維人員幫忙呢?因為公司很多基礎服務是自運維,開發者既做開發又是運維。

image

說完機器置換,再來聊聊這個基礎服務,它是一個Go寫的服務,不停地傳送HTTP請求,記住這點就好,其他不重要。

這個服務在置換機器後,HTTP請求的耗時慢了不少,如下圖,黃色為老機器,藍色為新機器,指標的值就是HTTP請求的耗時(毫秒),大概1.5倍的差距。這就是今天要分享的問題,接下來說說我的排查過程。

image

問題排查

這種情況,先去看了機器的各項指標,如CPU、網路情況等等,看看是否有異常,確認是否被其他指標影響了。但看了一圈下來,發現新機器的各項指標甚至還優於老機器。

接著去詢問了提供機器的同學,看看機器是否有異常,結果也是沒有。

既然HTTP請求變慢,就想到看看是請求的哪個環節變慢了,用如下的命令來測試下,域名我用百度的域名來代替:

curl -o /dev/null -s -w %{time_namelookup}::%{time_connect}::%{time_total}"\n" http://www.baidu.com

這裡的各個引數代表含義(還有一些其他引數也可用):

  • time_total 總時間,按秒計。精確到小數點後三位。
  • time_namelookup DNS解析時間,從請求開始到DNS解析完畢所用時間。
  • time_connect 連線時間,從開始到建立TCP連線完成所用時間,包括前邊DNS解析時間,如果需要單純的得到連線時間,用這個time_connect時間減去前邊time_namelookup時間。以下同理,不再贅述。
  • time_appconnect 連線建立完成時間,如SSL/SSH等建立連線或者完成三次握手時間。
  • time_pretransfer 從開始到準備傳輸的時間。
  • time_redirect 重定向時間,包括到最後一次傳輸前的幾次重定向的DNS解析,連線,預傳輸,傳輸時間。
  • time_starttransfer 開始傳輸時間。在client發出請求之後,Web 伺服器返回資料的第一個位元組所用的時間

這樣能看到域名解析、連線、傳輸各個階段的耗時情況,新老機器對比,如果有一項特別高,那麼這項肯定有問題

  • 新機器:0.001484::0.001743::0.007489
  • 老機器:0.000681::0.000912::0.002475

簡單計算一下:

  • 新機器:DNS解析耗時0.001484秒,連線建立耗時0.000258秒,總耗時0.007489秒
  • 老機器:DNS解析耗時0.000681秒,連線建立耗時0.000231秒,總耗時0.002475秒

雖然從這次的測試資料來看,新機器DNS解析似乎慢了一點,但你仔細看這個數值,幾乎對請求的總體耗時沒啥影響,而且多測試幾次,發現這兩臺機器的DNS解析其實差不多。

但還是不放心,驗證DNS是否存在問題,再用dig命令去試一下

dig www.baidu.com

執行時,明顯感覺到了卡頓,確定是DNS有問題了。

image

問題解決

一開始,我去網上搜尋了一下DNS慢的相關文章,找到了一篇文章《記一次Go net庫DNS問題排查》,但稍微驗證了下,和我的case沒啥關係,文章是好文章,所以也貼個連結,感興趣可以讀讀。

《記一次Go net庫DNS問題排查》https://juejin.cn/post/6948469896007122974

接著就去找了網路組的同學,網路組的同學稍微看了一眼就知道原因了,說新機器沒有安裝DNSmasq,這又是個啥?不要慌,先去網上查下再接話。

image

DNSmasq 提供 DNS 快取和 DHCP 服務功能。作為域名解析伺服器(DNS),DNSmasq可以通過快取 DNS 請求來提高對訪問過的網址的連線速度。作為DHCP 伺服器,DNSmasq 可以用於為區域網電腦分配內網ip地址和提供路由。DNS和DHCP兩個功能可以同時或分別單獨實現。DNSmasq輕量且易配置,適用於個人使用者或少於50臺主機的網路。此外它還自帶了一個 PXE 伺服器。

簡單來說,這裡它扮演的是一個DNS快取的角色,提高DNS的查詢速度。

說到這裡,插播一個小知識,我一直以為DNS會被作業系統快取,不知道你們有沒有這樣的錯覺,但實際上,Linux下如果沒有特殊處理,每一次DNS解析都要查詢DNS伺服器。很好證明,可以用tcpdump抓DNS的包試試,我當時也試了下,每次都會去遠端拿DNS解析結果。這個結論在《TCP/IP詳解卷1》中也能找到相關的描述:

image

只有Windows和比較新的Linux系統可以在客戶端快取DNS,而且Linux系統是需要手動開啟的,所以預設情況下都要去遠端獲取DNS快取。

言歸正傳,網路組同學說要麼裝一個DNSmasq,要麼改下DNS伺服器的配置,也就是/etc/resolv.conf檔案,由於機器上已經有服務了,所以選擇了改配置這種比較安全的方式。

沒改之前,/etc/resolv.conf 的第一行是127.0.0.1,也就是將本地也作為DNS伺服器,但實際上本地沒有開啟DNS服務,網路組同學說,去掉第一行配置或者安裝DNSmasq都可以。

先是去掉了127.0.0.1的配置,結果耗時不變!

image

隨後加上127.0.0.1的配置,又安裝了DNSmasq後,耗時就降下去了。

image

整個解決的過程,程式沒有重啟,唯一的變數是安裝了DNSmasq,所以這一定是DNS的鍋了。

問題反思

雖然問題解決了,但我還有幾個疑問:

  1. 為什麼配置了127.0.0.1的DNS server,但沒有開啟DNSmasq呢?
  2. 為什麼去掉127.0.0.1配置會無效呢?

第1個問題比較好搞清楚,問了下系統部的同學,他說本來是應該開啟DNSmasq的,但出了一點點小差錯,結果只配置了127.0.0.1。

image

再看第2個問題,DNS本地快取和遠端查詢差距這麼大嗎?據網路組同學說DNS server是公司內自建的,內網傳輸,實際並不慢,用dig也好測試,使用第2、3行的DNS server測試下,發現dig的速度都很快。

dig www.baidu.com @host

為什麼有了127.0.0.1的配置就變得很慢呢?下面就從我的幾個猜測入手,一個個證明,但在猜測之前,我們先了解一下Go程式解析DNS的流程。

Go的DNS解析流程

Go的DNS解析分為兩種:

  • cgo方式,呼叫c語言標準庫的實現
  • 純Go程式碼實現

由於要適配各個平臺,所以又有了各個平臺的實現。

這部分程式碼位於net包下,想要跟蹤也很簡單,寫個建立連線的程式碼,一步步debug,找到域名解析的地方。

我直接告訴你從lookup_unix.go檔案的lookupIP方法看起,當然這只是Unix系統,包括Mac和Linux,不過Mac不走純Go的程式碼,它被強制走到cgo了,在Linux上沒有特殊配置是走純Go實現的DNS解析,以下程式碼以Linux為例:

func (r *Resolver) lookupIP(ctx context.Context, network, host string) (addrs []IPAddr, err error) {
	// ①強制走純Go的DNS解析器
	if r.preferGo() {
		return r.goLookupIP(ctx, host)
	}
	// ②根據解析順序解析
	order := systemConf().hostLookupOrder(r, host)
	if order == hostLookupCgo {
		if addrs, err, ok := cgoLookupIP(ctx, network, host); ok {
			return addrs, err
		}
		// cgo not available (or netgo); fall back to Go's DNS resolver
		// ③如果cgo搞不定,降級到先檔案再DNS
		order = hostLookupFilesDNS
	}
	ips, _, err := r.goLookupIPCNAMEOrder(ctx, host, order)
	return ips, err
}

這裡order有如下幾種

hostLookupCgo      hostLookupOrder = iota // cgo
hostLookupFilesDNS                 // 檔案優先
hostLookupDNSFiles                 // DNS優先
hostLookupFiles                    // 只查檔案
hostLookupDNS                      // 只查DNS

這裡的檔案也就是/etc/hosts,goLookupIP 最終也呼叫了 goLookupIPCNAMEOrder,但goLookupIPCNAMEOrder這個方法的程式碼太長,所以我這裡只講一下大致的流程:

  1. 如果需要先查詢hosts檔案,則先查,查到直接返回
  2. 讀取/etc/resolv.conf檔案,拿出DNS server的配置,並且每5秒更新一次
  3. 構造DNS請求並向伺服器傳送,UDP讀取的超時時間預設為5秒,可在/etc/resolv.conf檔案中配置,同一個域名的不同型別(如ipv4和ipv6)的查詢可配置為並行或序列
  4. 向DNS server傳送請求採用的是輪詢機制,如果其中一個server請求出錯,則順延至下一個,重試次數預設為2,可在/etc/resolv.conf檔案中配置
  5. 最後解析查詢結果並返回,如果結果為空,且配置了hosts檔案兜底,則查詢一次檔案

好了,流程簡單介紹到這裡,接下來驗證我的幾個猜想。

猜想一:Go是否只在程式啟動時讀取一次/etc/resolv.conf檔案

這個猜想的依據是,如果查詢DNS時拿到了127.0.0.1的DNS server,且本地未開啟DNS服務時,可能會慢,且配置檔案如果修改了,Go程式如果只在初始化時讀一次檔案,那自然改配置檔案無效。

但事實並非如此,上面也說了,Go在讀取DNS配置檔案時是惰性地每隔5秒更新一次

func (conf *resolverConfig) tryUpdate(name string) {
	// 初始化,只做一次
  conf.initOnce.Do(conf.init)
  // ...
	now := time.Now()
	if conf.lastChecked.After(now.Add(-5 * time.Second)) {
		return
	}
	conf.lastChecked = now
  // ... 
	dnsConf := dnsReadConfig(name)
	conf.mu.Lock()
	conf.dnsConfig = dnsConf
	conf.mu.Unlock()
}

而且我做了個實驗,寫了個DNS解析的測試程式碼,放在有127.0.0.1配置但未開啟DNSmasq的伺服器上跑,抓127.0.0.1 53埠(DNS預設埠)的包,發現是有流量的,然後修改/etc/resolv.conf配置,去掉127.0.0.1,發現抓不到127.0.0.1 53埠的流量了,這證明和程式碼邏輯一致,本猜想不成立。

猜想二:DNS查詢遠端比本地慢很多

這個很好證明,還是用上面的程式

  1. 放在無127.0.0.1配置的伺服器上跑
  2. 放在有127.0.0.1配置且開啟DNSmasq的伺服器上跑

結果兩者耗時差不多,甚至他們和在有127.0.0.1配置但未開啟DNSmasq的伺服器上的耗時也基本一致。

這說明無論怎樣查詢DNS都不慢。

猜想三:是否是併發太高導致

為什麼我會有這個猜想呢,一是線上的QPS大概是50左右,和上面測試的場景不太一樣,二是我在上面的程式碼中看到了鎖,是不是併發高了之後,鎖帶來的開銷變大導致?

我寫了個100併發的程式碼,去查詢DNS,結果發現這段程式碼在如下三種場景,耗時都差不多

  1. 無127.0.0.1配置的伺服器
  2. 有127.0.0.1配置且開啟DNSmasq的伺服器
  3. 有127.0.0.1配置且未開啟DNSmasq的伺服器

同時我也去問了網路組的同學,他說DNS server能抗住百萬QPS,服務端沒有壓力。

image

最後

寫到最後,我emo了~雖然問題解決了,但為什麼當時DNS查詢慢還是不知道,如果你看了文章知道其中哪裡有問題,或者有什麼比較好的排查方法,歡迎來探討,反正我是查不下去了。

最後再說一句,寫文章很辛苦,需要點鼓勵,來個點贊在看關注吧,我們下期再見。

搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐。

image

相關文章