Localhost 就一定是 Localhost 么?
我們?cè)诒镜販y(cè)試或者本地通訊的時(shí)候經(jīng)常使用 localhost 域名,但是訪問(wèn) localhost 的對(duì)應(yīng)的一定就是我們的本機(jī)地址么?
背景
在一個(gè)風(fēng)和日麗下午,突然收到了運(yùn)維同學(xué)的反饋,說(shuō)我們的一個(gè)服務(wù)調(diào)用突然報(bào)錯(cuò)了,關(guān)鍵是這個(gè)服務(wù)已經(jīng)半年沒(méi)有更新發(fā)版過(guò)了,詢問(wèn)后得知最近基礎(chǔ)架構(gòu)也沒(méi)有什么變更,這就很迷了
我們排查日志后發(fā)現(xiàn)這個(gè)服務(wù)去調(diào)用了一個(gè)不知名的 ip 地址,這個(gè)地址還能 ping 通,但是我們明明是配置的 localhost,為什么會(huì)出現(xiàn)這個(gè)地址?localhost 不應(yīng)該指向的是 127.0.0.1 么?我們使用 dig 和 nslookup 之后發(fā)現(xiàn) localhost 的確是 127.0.0.1。
我們修改了應(yīng)用的配置,讓這個(gè)調(diào)用直接調(diào)用 127.0.0.1 結(jié)果發(fā)現(xiàn)這個(gè)時(shí)候服務(wù)就正常了,然后我們?cè)跈C(jī)器上抓包之后發(fā)現(xiàn) localhost 竟然走了域名解析! 并且 localhost 這個(gè)域名在我們內(nèi)網(wǎng)還被注冊(cè)了,解析出來(lái)的地址就是最開(kāi)始發(fā)現(xiàn)的這個(gè)不知名的地址
小結(jié)
所以我們下意識(shí)認(rèn)為的域名解析流程應(yīng)該是這樣的,先去找 /etc/hosts 文件,localhost 找到了(默認(rèn)是 127.0.0.1)就返回了
排查之后發(fā)現(xiàn),實(shí)際上的流程是這樣的,先做了 DNS 查詢 DNS 沒(méi)查到然后去查了 /etc/hosts 文件
直到有一天,我們的內(nèi)網(wǎng)域名解析中添加了一個(gè) localhost 的域名解析,就直接查詢成功返回了
復(fù)現(xiàn)
我們先使用一段簡(jiǎn)單的代碼復(fù)現(xiàn)一下,簡(jiǎn)單請(qǐng)求一下 localhost 就行了
- package main
- import (
- "fmt"
- "net/http"
- )
- func main() {
- client := &http.Client{}
- _, err := client.Get("http://localhost:8080")
- fmt.Println(err)
- }
然后我們使用 GODEBUG="netdns=go+2" 環(huán)境變量執(zhí)行程序,帶上這個(gè)環(huán)境變量之后程序運(yùn)行時(shí)就會(huì)輸出是先執(zhí)行 dns 查詢還是先從 /etc/hosts 文件進(jìn)行查詢
- GODEBUG="netdns=go+2" go run main.go
- go package net: GODEBUG setting forcing use of Go's resolver
- go package net: hostLookupOrder(localhost) = files,dns
- Get "http://localhost:8080": dial tcp [::1]:8080: connect: connection refused
上面顯示的 files,dns 的意思就是先從 /etc/hosts 文件中查詢,再去查詢 dns 結(jié)果,但是我們當(dāng)時(shí)服務(wù)的運(yùn)行結(jié)果是 dns,files 這個(gè)問(wèn)題出現(xiàn)在哪里呢?和 Go 的版本以及本地環(huán)境有關(guān)系
我們使用 Docker 模擬了線上環(huán)境,我們線上也是用的 Docker
- FROM golang:1.15 as builder
- WORKDIR /app
- COPY main.go main.go
- COPY run.sh run.sh
- ENV CGO_ENABLED=0
- ENV GOOS=linux
- RUN go build main.go
- FROM alpine:3
- WORKDIR /app
- COPY --from=builder /app /app
- COPY run.sh run.sh
- RUN chmod +x run.sh
- ENV GODEBUG="netdns=go+2"
- ENV CGO_ENABLED=0
- ENV GOOS=linux
- CMD /app/run.sh
使用這個(gè)容器運(yùn)行的結(jié)果如下,可以看到已經(jīng)變成了 dns,files 為什么會(huì)這樣呢?
- go package net: built with netgo build tag; using Go's DNS resolver
- go package net: hostLookupOrder(localhost) = dns,files
- Get "http://localhost:8080": dial tcp 127.0.0.1:8080: connect: connection refused
排查
src/net/dnsclient_unix.go
Go 中定義了下面幾種 DNS 解析順序,其中 files 表示查詢 /etc/hosts 文件,dns 表示執(zhí)行 dns 查詢
- // hostLookupOrder specifies the order of LookupHost lookup strategies.
- // It is basically a simplified representation of nsswitch.conf.
- // "files" means /etc/hosts.
- type hostLookupOrder int
- const (
- // hostLookupCgo means defer to cgo.
- hostLookupCgo hostLookupOrder = iota
- hostLookupFilesDNS // files first
- hostLookupDNSFiles // dns first
- hostLookupFiles // only files
- hostLookupDNS // only DNS
- )
在 src/net/conf.go 中可以看到
Go 會(huì)先根據(jù)一些初始條件判斷查詢的順序,然后就查找 /etc/nsswitch.conf 文件中的 hosts 配置項(xiàng),如果不存在就會(huì)走一些回退邏輯。這次的問(wèn)題出現(xiàn)在這個(gè)回退邏輯上
- func (c *conf) hostLookupOrder(r *Resolver, hostname string) (ret hostLookupOrder) {
- // ... 省略
- nss := c.nss
- srcs := nss.sources["hosts"]
- // If /etc/nsswitch.conf doesn't exist or doesn't specify any
- // sources for "hosts", assume Go's DNS will work fine.
- if os.IsNotExist(nss.err) || (nss.err == nil && len(srcs) == 0) {
- if c.goos == "solaris" {
- // illumos defaults to "nis [NOTFOUND=return] files"
- return fallbackOrder
- }
- if c.goos == "linux" {
- // glibc says the default is "dns [!UNAVAIL=return] files"
- // https://www.gnu.org/software/libc/manual/html_node/Notes-on-NSS-Configuration-File.html.
- return hostLookupDNSFiles
- }
- return hostLookupFilesDNS
- }
- if nss.err != nil {
- // We failed to parse or open nsswitch.conf, so
- // conservatively assume we should use cgo if it's
- // available.
- return fallbackOrder
- }
- }
通過(guò)上面的代碼我們可以發(fā)現(xiàn),當(dāng)前系統(tǒng)如果是 linux 并且不存在 /etc/nsswitch.conf 文件的時(shí)候,會(huì)直接返回 dns,files 的順序,這個(gè)是參考了 glibc 的實(shí)現(xiàn)[^2]
這個(gè)問(wèn)題其實(shí)一般在虛擬機(jī)上沒(méi)有問(wèn)題,因?yàn)橐话悴僮飨到y(tǒng)都會(huì)默認(rèn)有這個(gè)配置文件,但是容器化之后我們一般喜歡使用 alpine linux 這種比較小的基礎(chǔ)鏡像,alpine 中就不存在的 /etc/nsswitch.conf 這個(gè)文件,所以就有可能會(huì)出現(xiàn)問(wèn)題
上面這段邏輯不能再 1.16 中進(jìn)行復(fù)現(xiàn),是因?yàn)?1.16 已經(jīng)修改了這個(gè)邏輯,主要就是把 linux 的這個(gè)判斷分支刪除掉了,感興趣可以看這個(gè)修改記錄[^3] 和這個(gè) issue[^4]
總結(jié)
最大的感受就是經(jīng)驗(yàn)主義害死人,很多時(shí)候由于我們知識(shí)點(diǎn)的原因所以可能會(huì)出現(xiàn)一些和我們認(rèn)為的常識(shí)相違背的地方,這個(gè)時(shí)候就需要大膽假設(shè)小心求證了
針對(duì)這次這個(gè)問(wèn)題的修復(fù)方案,我們是直接先刪除了 localhost 的解析,復(fù)盤(pán)之后給出我不成熟的幾點(diǎn)小建議
- 公司內(nèi)網(wǎng)就不要搞注冊(cè) localhost 域名這種騷操作了
- 基礎(chǔ)鏡像的維護(hù)很重要,建議大家最好能夠統(tǒng)一一個(gè)基礎(chǔ)鏡像這樣不僅僅可以減少一些磁盤(pán)空間,同時(shí)還可以做一些統(tǒng)一的變更,例如這次這種就可以直接在基礎(chǔ)鏡像加上 /etc/nsswitch.conf 文件,避免其他業(yè)務(wù)也進(jìn)坑里
- 如果沒(méi)有什么特別的版本依賴(絕大部分應(yīng)用其實(shí)都沒(méi)有)Go 版本建議升級(jí) 1.16 可以省很多事
- dns 解析并不一定會(huì)先查詢 hosts 文件,除了這種默認(rèn)的情況外,還可以手動(dòng)修改 /etc/nsswitch.conf 文件,調(diào)整解析的順序,這個(gè)感興趣的話可以試試
- 這篇文章還試著用 figma 做了幾個(gè)小動(dòng)畫(huà),感覺(jué)還是不錯(cuò),后續(xù)有空寫(xiě)文章可以再搞搞(曹大不要再卷了,快學(xué)不動(dòng)了)
參考文獻(xiàn)
[^1]: Go 1.14 標(biāo)準(zhǔn)庫(kù)源碼: https://github.com/golang/go/blob/go1.14/src/net/conf.go
[^2]: glibc 實(shí)現(xiàn) https://www.gnu.org/software/libc/manual/html_node/Notes-on-NSS-Configuration-File.html
[^3]: 修改記錄: https://github.com/golang/go/commit/c80022204e8fc36ec487888d471de27a5ea47e17#diff-a7c29e18c1a96d08fed3e81f367d079d14c53ea85d739e7460b21fb29a063128
[^4]: https://github.com/golang/go/issues/35305
博客原文:https://lailin.xyz/