背景

在 2022年12月,我们新搭建了一套 HDP 3.1.5 的 Hadoop 集群之后,内网域已有 5 套 hadoop 集群,服务器数量达到 2000+。

要将这么多服务器接入到 Prometheus 可不是件容易的事,更别说我们偶尔会变更服务器用途、集群扩容。

比如,我们正在进行 HDP2 集群 往 HDP3 集群迁移,为了完成数据生产程序的版本适配,前期已经将 4G 5G xDR 的 ETL 集群扩容,hdfs 输出双送到 HDP2 HDP3 的两套集群。

一旦 HDP3 迁移完成,可以预见即将带来的变更:

  • HDP2 集群拆掉
  • 4G xDR ETL 仅输出到 HDP3,机器缩容
  • 5G xDR ETL 仅输出到 HDP3,机器数量不变(因为之前有一个大业务没有上,加上后机器负载刚好达到饱和)
  • HDP2 集群拆掉的机器扩到 HDP3 集群

….

这些变更带来的影响:

  • 主机名域名解析更新
  • 集群主机变更
  • 告警规则,ETL 集群集群监控统计适配

计划需求

我希望设计一个程序,最少满足以下需求

  • 对 Prometheus 提供自动发现( Prom 支持 http_sd )
  • 可以快捷导出 hosts 文件(后来这个需求优化成了 DNS 服务器)

调研选型

在外网上搜了很久,服务器裸机方面的监控,一般是用 zabbix,或者是 ansible-awx。

zabbix 不适合统计业务指标
ansible-awx 搭建麻烦

最后我决定自己写,用 Golang,在外网测试好,交叉编译成 linux 二进制放到内网执行即可。

开发环境搭建

我们内网中的关系性数据库是 Oracle,在这之前我没有使用 golang 连接 oracle 的,需要一个开发环境,用 docker 可以启动一个 oracle

1
docker run -d --name oracle   --privileged -v $(pwd)/oradata:/u01/app/oracle   -p 8080:8080 -p 1521:1521 absolutapps/oracle-12c-ee

go 驱动使用 https://github.com/godror/godror

按照文档安装 oracle 驱动程序即可连接上

将表格入库

目前我们的机器信息在一个 Excel 文件中,需要读取出来,然后写入到 oracle 表中。

入库程序用其他语言写也行,如果也用 go 写,入库程序和 cmdb 程序的 struct 可以复用,刚好在 github 上发现一个解析 excel 的程序,可以开箱即用。

参考:https://github.com/douyacun/go-struct-excel

入库程序: https://github.com/meoww-bot/read-excel-go-oracle

错误的 IP

这里有个小插曲,就是我的 navicat 能连上 oracle 数据库,golang 程序连不上。

然后我找了一圈,发现 navicat 用的是域名,golang 程序里面配置的是 IP 地址,域名是在 cloudflare 上配置的,但是 IP 是我用 dig +short <domain> 命令查的

后来仔细一看,原来是我前段时间入手了 Surge,开了增强模式,Surge 会创建一个虚拟网卡 (Surge VIF) 并配置其为默认路由。所用的 DNS 请求都会得到一个位于 198.18.0.0/15 段的虚拟地址。

来源:https://www.v2ex.com/t/899087

HTTP 服务发现接口

参考
https://prometheus.io/docs/prometheus/latest/http_sd/

格式

1
2
3
4
5
6
7
8
9
[
{
"targets": [ "<host>", ... ],
"labels": {
"<labelname>": "<labelvalue>", ...
}
},
...
]

因为考虑在 labels 增加机器健康状况,机器健康状况是对应每个机器的,所以我只能在 targets 里面塞一个 host

结果

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
[
{

"targets": [
"xxxx001:9100"
],
"labels": {
"biz": "master",
"ip": "10.110.1.1",
"job": "ose",
"status": "OK"
}

},
{

"targets": [
"xxxx002:9100"
],
"labels": {
"biz": "master",
"ip": "10.110.1.2",
"job": "ose",
"status": "OK"
}

},
{

"targets": [
"xxxx003:9100"
],
"labels": {
"biz": "master",
"ip": "10.110.1.3",
"job": "ose",
"status": "OK"
}

},
{

"targets": [
"xxxx004:9100"
],
"labels": {
"biz": "master",
"ip": "10.110.1.4",
"job": "ose",
"status": "OK"
}

},
...
]

把 IP 搞出来是因为有时候需要给外部系统提供 IP,比如

  1. 有次云池交换机割接,割接完后可能有些机器网络还是没有恢复,需要知道确定的 IP
  2. 其他厂家给我们传送数据时,数据不均衡,也需要提供给对方我们机器的 IP

DNS 服务器

consul

开始的计划是使用 consul 来完成 Prometheus 的服务发现,因为

  1. Prometheus 支持 consul
  2. 只有一个二进制,部署方便
  3. 自带一个 DNS 服务器
  4. 可以配置主机的维护信息( https://developer.hashicorp.com/consul/api-docs/agent/service#enable-maintenance-mode
  5. 自带了 health check,health check 结果也有 label,可以同步到 prometheus

部署了后发现,consul 的 DNS 是一种特定格式
https://developer.hashicorp.com/consul/docs/discovery/dns#node-lookups

1
<node>.node[.<datacenter>].<domain>

所以我放弃了 consul

使用 Go 实现

然后准备想用 bind 或者 dnsmasq。

后来突然开窍,DNS 服务器只是一个监听在 udp 53 端口上的,能对特定请求进行相应的服务端程序。

那么我可以自己写一个吧

使用 github.com/miekg/dns 这个库

这个库也是 coredns 和 consul 使用的

参考:https://jameshfisher.com/2017/08/04/golang-dns-server/

注意,域名后有个点 .

所以我只需要将数据库的结果查询出来,组装一下放到 map[string]string 即可

代码截取

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func GetAllHosts() map[string]string {

d := make(map[string]string)

invArray, err := db.GetInventoryHosts("all")

if err != nil {
panic(err)
}

for _, inv := range invArray {

if inv.Domain != "" {
fqdn := inv.ShortHostname + "." + inv.Domain
d[fqdn+"."] = inv.ServiceIp
}
d[inv.Hostname+"."] = inv.ServiceIp
d[strings.ToLower(inv.ShortHostname)+"."] = inv.ServiceIp

}

return d

}

var dict = GetAllHosts()

func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
msg := dns.Msg{}
msg.SetReply(r)

switch r.Question[0].Qtype {
case dns.TypeA:
msg.Authoritative = true
domain := msg.Question[0].Name
address, ok := dict[domain]
if ok {
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: domain, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 3600},
A: net.ParseIP(address),
})
}
}
w.WriteMsg(&msg)
}

DNS 从数据库重载主机信息

考虑到 CMDB 信息修改后,需要同步到 DNS 服务器,我不可能每次修改后要重启这个 cmdb-server 程序。

参考 prometheus 的设计,增加了一个 reload endpoint

handler Post

1
api.POST("/inventory/dns/reload", handler.InventoryDnsReload)

只需要再次请求获取全量机器信息即可

1
2
3
4
func InventoryDnsReload(c *gin.Context) {
dict = GetAllHosts()
log.Println("[DNS] reload DNS records success")
}

使用 curl 请求 reload

1
curl -X POST -u 'user':'pass' "http://127.0.0.1:8000/api/inventory/dns/reload"

自动化重载,可以通过 oracle 触发器执行外部脚本调用 curl 请求

压力测试

要考虑整个内网都用这个 DNS 服务器,还需要压力测试解析能力

使用 dnsperf 进行压力测试,参考: https://www.cnblogs.com/cobbliu/p/3872255.html

压力测试结果

同时启动 gin 和 DNS server

将 gin 的 路由监听放到 go 协程中

1
go router.Run(":8000")

使用 DNS server 的监听阻塞整个程序

1
2
3
4
5
6
srv := &dns.Server{Addr: ":53", Net: "udp"}
srv.Handler = &handler.Handler{}

if err := srv.ListenAndServe(); err != nil {
log.Fatalf("Failed to set udp listener %s\n", err.Error())
}

使用 CMDB 接入 Prometheus

prometheus 配置

1
2
3
4
5
6
7
8
9
10
- job_name: 'canal_4gxdr_new'
http_sd_configs:
- url: http://<cmdb-api>:8000/api/inventory/sd?cluster=canal_4gxdr
basic_auth:
username: ...
password: ...
relabel_configs:
- source_labels: [__address__]
regex: "([^:]+):\\d+"
target_label: instance

效果图

⚠️注意:

  1. relabel_configs 用于去掉 instance 中的端口号,grafana 面板会更加整洁

  2. 去掉端口号不会影响 Prometheus 抓取指标,Prometheus 会从 relabel 前的 __address__ 抓取指标

  3. labels 里面的 job 标签比 配置文件指定的优先级还高:配置文件我指定的 job 名称为 canal_4gxdr_new,是想和原有的 canal_4gxdr 区分开,结果却发现这些 targets 还是跑到老的 job 里面去了,把集群组的监控指标值都给污染了 😓 。 我只能把老的 canal_4gxdr 去掉了。

  1. 我原以为为要为每个 job 单独配置 服务发现链接。现在看来只需要配置一个,带出所有的即可,labels 里面有 job,prometheus 会根据这个自动分组

后续更新

更换 oracle 驱动

参考另文在 xorm 使用 go-ora 连接 oracle 数据库 - 导入指定 commit 的包

导出 DNS 服务器的 metrics

我想要知道 DNS 服务器提供了多少次 DNS 解析

同样参考 prometheus+node_exporter 的设计

给 gin 添加一个 metrics 端点

1
router.GET("/metrics", gin.WrapH(promhttp.Handler()))

handler/dns.go

定义指标名称

1
2
3
4
5
6
7
8
var (
dns_request_total = promauto.NewCounter(
prometheus.CounterOpts{
Name: "dns_request_total",
Help: "The total number of processed dns requests",
},
)
)

在 解析处理时给指标 inc
所以将 inc 放到 if ok 后面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
msg := dns.Msg{}
msg.SetReply(r)

switch r.Question[0].Qtype {
case dns.TypeA:
msg.Authoritative = true
domain := msg.Question[0].Name
address, ok := dict[domain]
if ok {
---> dns_request_total.Inc()
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{Name: domain, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 3600},
A: net.ParseIP(address),
})
}
}
w.WriteMsg(&msg)
}

如果有必要的话可以更加细化

一共收到的解析请求数量 dns_request_total

成功处理的解析请求数量 dns_answered_request_total

反向 DNS 记录(PTR)

有的时候别的系统给了个 IP,我想知道这个 IP 的主机名,难道必须要用 ping 吗

或者是 grep 'ip' /etc/hosts 这么low 的方式??

有没有高大上一点的,我们现在有 DNS 了,有没有通过 DNS 查询的方式呢?

有的

答案是 PTR 记录

  • A/AAAA 记录用于 域名 转换为 IP 地址

  • PTR 记录则相反,将 IP 地址转换为 域名

PTR记录的定义和实现可以参考 RFC 1035

Cloudflare 网站上对 PTR 记录的介绍 https://www.cloudflare.com/learning/dns/dns-records/dns-ptr-record/

查询过程

我简单归纳一下查询过程

比如我们要查询 1.2.3.4 这个 IP 的 PTR 记录

客户端实际发起的请求是查询 4.3.2.1.in-addr.arpa. 这个域名的 PTR 记录

是把你查询的 IP 地址按每段倒转过来,在后面加上.in-addr.arpa.,因为 PTR 记录存储在 DNS 的 .arpa 顶级域中。

.arpa 是一个主要用于管理网络基础设施的域,是为互联网定义的第一个顶级域名。

ARPA 是 Advanced Research Projects Agency(美国国防部高级研究计划署) 的缩写。

可能 ARPA 这个单词有些陌生,那么在后面加上 NET 呢? ARPANET 有没有更熟悉一点?

ARPANET 是 Internet 的前身。1969年由 ARPA 制定,用于军事连接的网络。

in-addr.arpa.arpa 中的一个命名空间,用于在 IPv4 中进行反向 DNS 查找。

实际使用

以 dig 命令为例查询 PTR 记录

1
dig -x 106.10.150.171

响应

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
28
29
30
31
32
33
34
; <<>> DiG 9.10.6 <<>> -x 106.10.150.171
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 2929
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 6, ADDITIONAL: 7

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;171.150.10.106.in-addr.arpa. IN PTR

;; ANSWER SECTION:
171.150.10.106.in-addr.arpa. 300 IN PTR unknown.yahoo.com.

;; AUTHORITY SECTION:
in-addr.arpa. 285 IN NS b.in-addr-servers.arpa.
in-addr.arpa. 285 IN NS f.in-addr-servers.arpa.
in-addr.arpa. 285 IN NS a.in-addr-servers.arpa.
in-addr.arpa. 285 IN NS c.in-addr-servers.arpa.
in-addr.arpa. 285 IN NS d.in-addr-servers.arpa.
in-addr.arpa. 285 IN NS e.in-addr-servers.arpa.

;; ADDITIONAL SECTION:
a.in-addr-servers.arpa. 285 IN A 199.180.182.53
b.in-addr-servers.arpa. 285 IN A 199.253.183.183
c.in-addr-servers.arpa. 285 IN A 196.216.169.10
d.in-addr-servers.arpa. 285 IN A 200.10.60.53
e.in-addr-servers.arpa. 285 IN A 203.119.86.101
f.in-addr-servers.arpa. 285 IN A 193.0.9.1

;; Query time: 2481 msec
;; SERVER: 198.18.0.2#53(198.18.0.2)
;; WHEN: Sun Mar 05 15:46:54 CST 2023
;; MSG SIZE rcvd: 295

可以看到我们的请求

1
2
;; QUESTION SECTION:
;171.150.10.106.in-addr.arpa. IN PTR

结果

1
2
;; ANSWER SECTION:
171.150.10.106.in-addr.arpa. 300 IN PTR unknown.yahoo.com.

程序实现

还是要生成一个 PTR 记录的关系,即 <ip>.in-addr.arpa. 和 域名的 map。

dns.ReverseAddr() 函数可以用于转换 IP 地址到 <ip>.in-addr.arpa. 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func GetAllHostsPTR() map[string]string {

d := make(map[string]string)

invArray, err := db.GetInventoryHosts("all")

if err != nil {
panic(err)
}

for _, inv := range invArray {

ptr_address, _ := dns.ReverseAddr(inv.ServiceIp)

d[ptr_address] = inv.Hostname + "."

}

return d

}

在 DNS 请求处理部分新增一个 case

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
28
29
30
31
32
33
34
35
36
37
38
39
var ptr_dict = GetAllHostsPTR()

func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
msg := dns.Msg{}
msg.SetReply(r)

switch r.Question[0].Qtype {
case dns.TypeA:
msg.Authoritative = true
domain := msg.Question[0].Name
address, ok := dict[domain]
if ok {
dns_request_total.Inc()
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: domain,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 3600},
A: net.ParseIP(address),
})
}
case dns.TypePTR:
msg.Authoritative = true
ptr_address := msg.Question[0].Name
_, ok := ptr_dict[ptr_address]
if ok {
dns_request_total.Inc()
msg.Answer = append(msg.Answer, &dns.PTR{
Hdr: dns.RR_Header{
Name: ptr_address,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: 3600},
Ptr: domain,
})
}
}
w.WriteMsg(&msg)

效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dig @127.0.0.1 -p5300 -x 1.2.3.4

; <<>> DiG 9.10.6 <<>> @127.0.0.1 -p5300 -x 1.2.3.4
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 18540
;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
;; WARNING: recursion requested but not available

;; QUESTION SECTION:
;4.3.2.1.in-addr.arpa. IN PTR

;; ANSWER SECTION:
4.3.2.1.in-addr.arpa. 3600 IN PTR google.com.

;; Query time: 0 msec
;; SERVER: 127.0.0.1#5300(127.0.0.1)
;; WHEN: Thu Mar 02 09:53:28 CST 2023
;; MSG SIZE rcvd: 148

注意:

  1. 返回的域名结尾要带 .,否则 dig 不会有结果,dig 会认为这个结果是 invalid 的,比如baidu.com不行,得baidu.com.
  2. 如果域名格式不合法,比如返回 .baidu.com,就会出现Got bad packet这样的结果

新增多个 PTR 记录

后来我想,能不能把 fqdn 和 短域名加上呢?这是一对多的关系。

IP 对应多个 域名。

因为我看到

1
msg.Answer = append(...)

如果要新增一个结果,再 append 即可。

但是 一对多的 map 关系怎么生成呢?

我想到了两种办法

  1. 将 DNS 请求中的 <ip>.in-addr.arpa. 格式转换成 ip,从 allhosts 里面反向提取 域名
  2. 提前生成好所有 IP 对应的 <ip>.in-addr.arpa. 格式,因为要一对多的关系,所以将 域名作为 k,ptr记录作为 v 放到 dict 中 (map[string]string)

我这里采用第 2 种

更新生成 map 的代码

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
func GetAllHostsPTR() map[string]string {

d := make(map[string]string)

invArray, err := db.GetInventoryHosts("all")

if err != nil {
panic(err)
}

for _, inv := range invArray {

ptr_address, _ := dns.ReverseAddr(inv.ServiceIp)

if inv.Domain != "" {
fqdn := inv.ShortHostname + "." + inv.Domain
d[fqdn+"."] = ptr_address
}
d[inv.Hostname+"."] = ptr_address
d[strings.ToLower(inv.ShortHostname)+"."] = ptr_address
}

return d

}

DNS 请求处理

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
var ptr_dict = GetAllHostsPTR()

func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
msg := dns.Msg{}
msg.SetReply(r)

switch r.Question[0].Qtype {
case dns.TypeA:
msg.Authoritative = true
domain := msg.Question[0].Name
address, ok := dict[domain]
if ok {
dns_request_total.Inc()
msg.Answer = append(msg.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: domain,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 3600},
A: net.ParseIP(address),
})
}

case dns.TypePTR:
msg.Authoritative = true
ptr_address := msg.Question[0].Name
for k, v := range ptr_dict {

if v == ptr_address {
dns_request_total.Inc()
msg.Answer = append(msg.Answer, &dns.PTR{
Hdr: dns.RR_Header{
Name: ptr_address,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: 3600},
Ptr: k,
})
}

}

}
w.WriteMsg(&msg)
}

调试发现, ptr_dict 这个 map 的长度是 5340.

查询结果

可以看到,虽然每次要从 5340 中遍历结果,但是性能没受到影响

1
Query time: 0 msec