4. localhost 就一定是 localhost 么?

注:本文所有示例代码都可以在 blog-code 仓库中找到

我们在本地测试或者本地通讯的时候经常使用 localhost 域名,但是访问 localhost 的对应的一定就是我们的本机地址么?

背景

在一个风和日丽下午,突然收到了运维同学的反馈,说我们的一个服务调用突然报错了,关键是这个服务已经半年没有更新发版过了,询问后得知最近基础架构也没有什么变更,这就很迷了

我们排查日志后发现这个服务去调用了一个不知名的 ip 地址,这个地址还能 ping 通,但是我们明明是配置的 localhost,为什么会出现这个地址?localhost 不应该指向的是 127.0.0.1 么?我们使用 dignslookup 之后发现 localhost 的确是 127.0.0.1

我们修改了应用的配置,让这个调用直接调用 127.0.0.1 结果发现这个时候服务就正常了,然后我们在机器上抓包之后发现 localhost 竟然走了域名解析! 并且 localhost 这个域名在我们内网还被注册了,解析出来的地址就是最开始发现的这个不知名的地址

小结

所以我们下意识认为的域名解析流程应该是这样的,先去找 /etc/hosts 文件,localhost 找到了(默认是 127.0.0.1)就返回了

1

排查之后发现,实际上的流程是这样的,先做了 DNS 查询 DNS 没查到然后去查了 /etc/hosts 文件

2

直到有一天,我们的内网域名解析中添加了一个 localhost 的域名解析,就直接查询成功返回了

复现

我们先使用一段简单的代码复现一下,简单请求一下 localhost 就行了

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"fmt"
"net/http"
)

func main() {
client := &http.Client{}
_, err := client.Get("http://localhost:8080")
fmt.Println(err)
}

然后我们使用 GODEBUG="netdns=go+2" 环境变量执行程序,带上这个环境变量之后程序运行时就会输出是先执行 dns 查询还是先从 /etc/hosts 文件进行查询

1
2
3
4
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 结果,但是我们当时服务的运行结果是 dns,files 这个问题出现在哪里呢?和 Go 的版本以及本地环境有关系

我们使用 Docker 模拟了线上环境,我们线上也是用的 Docker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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

使用这个容器运行的结果如下,可以看到已经变成了 dns,files 为什么会这样呢?

1
2
3
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 表示执行 dns 查询

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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 会先根据一些初始条件判断查询的顺序,然后就查找 /etc/nsswitch.conf 文件中的 hosts 配置项,如果不存在就会走一些回退逻辑。这次的问题出现在这个回退逻辑上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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
}
}

通过上面的代码我们可以发现,当前系统如果是 linux 并且不存在 /etc/nsswitch.conf 文件的时候,会直接返回 dns,files 的顺序,这个是参考了 glibc 的实现[2]

这个问题其实一般在虚拟机上没有问题,因为一般操作系统都会默认有这个配置文件,但是容器化之后我们一般喜欢使用 alpine linux 这种比较小的基础镜像,alpine 中就不存在的 /etc/nsswitch.conf 这个文件,所以就有可能会出现问题

上面这段逻辑不能再 1.16 中进行复现,是因为 1.16 已经修改了这个逻辑,主要就是把 linux 的这个判断分支删除掉了,感兴趣可以看这个修改记录[3] 和这个 issue[4]

总结

最大的感受就是经验主义害死人,很多时候由于我们知识点的原因所以可能会出现一些和我们认为的常识相违背的地方,这个时候就需要大胆假设小心求证了

针对这次这个问题的修复方案,我们是直接先删除了 localhost 的解析,复盘之后给出我不成熟的几点小建议

  • 公司内网就不要搞注册 localhost 域名这种骚操作了
  • 基础镜像的维护很重要,建议大家最好能够统一一个基础镜像这样不仅仅可以减少一些磁盘空间,同时还可以做一些统一的变更,例如这次这种就可以直接在基础镜像加上 /etc/nsswitch.conf 文件,避免其他业务也进坑里
  • 如果没有什么特别的版本依赖(绝大部分应用其实都没有)Go 版本建议升级 1.16 可以省很多事
  • dns 解析并不一定会先查询 hosts 文件,除了这种默认的情况外,还可以手动修改 /etc/nsswitch.conf 文件,调整解析的顺序,这个感兴趣的话可以试试

这篇文章还试着用 figma 做了几个小动画,感觉还是不错,后续有空写文章可以再搞搞(曹大不要再卷了,快学不动了)

参考文献

关注我获取更新

wechat
知乎
开发者头条
github

猜你喜欢