背景
在 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,比如
- 有次云池交换机割接,割接完后可能有些机器网络还是没有恢复,需要知道确定的 IP
- 其他厂家给我们传送数据时,数据不均衡,也需要提供给对方我们机器的 IP
DNS 服务器
consul
开始的计划是使用 consul 来完成 Prometheus 的服务发现,因为
- Prometheus 支持 consul
- 只有一个二进制,部署方便
- 自带一个 DNS 服务器
- 可以配置主机的维护信息( https://developer.hashicorp.com/consul/api-docs/agent/service#enable-maintenance-mode )
- 自带了 health check,health check 结果也有 label,可以同步到 prometheus
部署了后发现,consul 的 DNS 是一种特定格式
https://developer.hashicorp.com/consul/docs/discovery/dns#node-lookups1
<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
45func 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 Post1
api.POST("/inventory/dns/reload", handler.InventoryDnsReload)
只需要再次请求获取全量机器信息即可1
2
3
4func InventoryDnsReload(c *gin.Context) {
dict = GetAllHosts()
log.Println("[DNS] reload DNS records success")
}
使用 curl 请求 reload1
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
6srv := &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
效果图
⚠️注意:
relabel_configs 用于去掉 instance 中的端口号,grafana 面板会更加整洁
去掉端口号不会影响 Prometheus 抓取指标,Prometheus 会从 relabel 前的
__address__
抓取指标labels 里面的 job 标签比 配置文件指定的优先级还高:配置文件我指定的 job 名称为
canal_4gxdr_new
,是想和原有的canal_4gxdr
区分开,结果却发现这些 targets 还是跑到老的 job 里面去了,把集群组的监控指标值都给污染了 😓 。 我只能把老的canal_4gxdr
去掉了。
- 我原以为为要为每个 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
8var (
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
19func (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 | func GetAllHostsPTR() map[string]string { |
在 DNS 请求处理部分新增一个 case1
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
39var 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
20dig @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
注意:
- 返回的域名结尾要带
.
,否则 dig 不会有结果,dig 会认为这个结果是 invalid 的,比如baidu.com
不行,得baidu.com.
- 如果域名格式不合法,比如返回
.baidu.com
,就会出现Got bad packet
这样的结果
新增多个 PTR 记录
后来我想,能不能把 fqdn 和 短域名加上呢?这是一对多的关系。
IP 对应多个 域名。
因为我看到1
msg.Answer = append(...)
如果要新增一个结果,再 append 即可。
但是 一对多的 map 关系怎么生成呢?
我想到了两种办法
- 将 DNS 请求中的
<ip>.in-addr.arpa.
格式转换成 ip,从 allhosts 里面反向提取 域名 - 提前生成好所有 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
25func 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
45var 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