zhangguanzhang's Blog

在 IPv6 单栈下适配部署 K8S、中间件、应用适配

字数统计: 3.9k阅读时长: 19 min
2024/08/02

记录近期 IPv6 单栈下 一些坑

由来

有客户的环境是 IPv6 单栈环境,需要部署我们产品,记录下适配遇到的坑。

过程

环境搭建

内部的虚机都是在 exsi 上,而到我们办公网台式机到 exsi 的链路上的交换机都不支持 IPv6 转发。但是视角放在同一个 exsi host 上,上面的所有虚机都是在 exsi 上的 vswitch 上,vswitch 是(内核)支持 IPv6 转发的。所以初步的模拟是一个 exsi host 上开下面的四台机器:

1
2
3
4
5
6
7
8
9
10
                                             
IPv4+IPv6 IPv6 IPv6 IPv6
┌──────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
└──┬───┘ └───┬───┘ └───┬───┘ └───┬───┘
│ │ │ │
│ │ │ │
│ │ │ │
└──────────────┴─────────┴─────────┘

因为还涉及到后面的浏览器访问测试,所以左边是 Windows :

Os IPv4 IPv6 描述
Windows x.x.x.x 2002:db8:0:1::1/64 也是下面三台机器的网关
Centos 7.8 2002:db8:0:1::101/64
Centos 7.8 2002:db8:0:1::102/64
Centos 7.8 2002:db8:0:1::103/64

网卡配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TYPE=Ethernet
PROXY_METHOD=none
BROWSER_ONLY=no
BOOTPROTO=static
#IPV6_AUTOCONF=yes
IPV6_DEFROUTE=yes
NAME=ens160
UUID=8813b773-201c-4fe1-80b3-5ab62586xxxx
DEVICE=ens160
ONBOOT=yes
IPV6_PRIVACY=no
IPV6INIT=yes
IPV6_AUTOCONF=no
IPV6ADDR=2002:db8:0:1::101/64
IPV6_DEFAULTGW=2002:db8:0:1::1

centos7 记得删掉 IPV6_ADDR_GEN_MODE=stable-privacy,不然我发现 IP 不生效,windows 记得开 文件和打印机共享(回显请求) 的 ipv4 ipv6 。

一些知识

IPv6 地址总长度为 128 比特,通常分为 8 组,每组为 4 个十六进制数的形式,每组十六进制数间用冒号分隔。 例如:

  • FC00:0000:130F:0000:0000:09C0:876A:130B
  • FC00:0000:130F::09C0:876A:130B 和上面是一样的,连续的 0 可以缩写,只能一处缩写

bind 的地址:

  • :: 等同于 ipv4 的 0.0.0.0
  • ::1 等同于ipv4 的 127.0.0.1localhost

url 格式是 http(s)://[xxx]:port ,host 部分多了方括号。

K8S

内部有 ansible 部署的,主要是基础上修改,之前节点名都是用的 IPv4 地址,直接部署后报错 kubelet 节点名无效,需要 RFC 1123 命名规范:

1
2
3
4
5
kubelet_node_status.go:92] "Unable to register node with API server" err="Node \"2002.db8.0.1..101\" is invalid: metadata.name: 
Invalid value: \"2002.db8.0.1..101\":
a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.',
and must start and end with an alphanumeric character (e.g. 'example.com',
regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')" node="2002.db8.0.1..101"

从1.21 版本支持双栈,下面的 CIDR 我只配置 IPv6 单栈,总结下改的点:

  • : 改成 . 当 nodeName 不行,因为存在缩写情况会被替换成两个 .. 而不匹配 RFC 1123 报错。
  • kubelet csr 的 CN "CN": "system:node:xxx", 也要和节点一致
  • 因为是单栈,涉及到 127.0.0.1 和 0.0.0.0 的都改成对应 IPv6
  • Service CIDR 设置为 2001:cafe:43::/112
    • kubernetes service 会使用第一个主机位 IP 2001:cafe:43::1,记得和 ::1 加到 csr 的证书 IP 里
    • DNS IP 是第十个主机位,我设置的 2001:cafe:43::a
  • Cluster CIDR 设置为 2001:cafe:42::/56

两个网段来源于参考 k3s 的 dual-stack-ipv4–ipv6-networking

flannel

最终部署完发现 flannel 报错:

1
2
3
4
5
6
7
8
9
10
11
12
vxlan_network.go:265] failed to add v6 vxlanRoute (2002:db8:42:2::/64 -> 2002:db8:42:2::): All attempts fail:
#1: no route to host
#2: no route to host

retry.go:29] #0: no route to host
retry.go:29] #1: no route to host
retry.go:29] #2: no route to host

# 路由信息
# ip -6 r s | grep flannel
2001:cafe:42:: dev flannel-v6.1 proto kernel metric 256 pref medium
fe80::/64 dev flannel-v6.1 proto kernel metric 256 pref medium

最终在官方仓库文档找到说明:

1
2
# https://github.com/flannel-io/flannel/blob/master/Documentation/configuration.md#dual-stack
vxlan support ipv6 tunnel require kernel version >= 3.12

Centos 7 升级内核解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ kubectl  get node -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
2002.0db8.0000.0001.0000.0000.0000.0101 Ready master,node 11d v1.27.15 2002:db8:0:1::101 <none> CentOS Linux 7 (Core) 4.19.113-300.el7.x86_64 docker://25.0.5
2002.0db8.0000.0001.0000.0000.0000.0102 Ready master,node 11d v1.27.15 2002:db8:0:1::102 <none> CentOS Linux 7 (Core) 4.19.113-300.el7.x86_64 docker://25.0.5
2002.0db8.0000.0001.0000.0000.0000.0103 Ready master,node 11d v1.27.15 2002:db8:0:1::103 <none> CentOS Linux 7 (Core) 4.19.113-300.el7.x86_64 docker://25.0.5
$ kubectl get pod -n kube-system -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
coredns-5b69bc466f-bkv7t 1/1 Running 1 (7d4h ago) 11d 2001:cafe:42:2::1a 2002.0db8.0000.0001.0000.0000.0000.0103 <none> <none>
coredns-5b69bc466f-f8g7g 1/1 Running 1 (7d4h ago) 7d6h 2001:cafe:42:1::1a 2002.0db8.0000.0001.0000.0000.0000.0102 <none> <none>
coredns-5b69bc466f-svvgz 1/1 Running 1 11d 2001:cafe:42:1::1b 2002.0db8.0000.0001.0000.0000.0000.0102 <none> <none>
kube-flannel-ds-g6bkw 1/1 Running 5 (3d ago) 11d 2002:db8:0:1::101 2002.0db8.0000.0001.0000.0000.0000.0101 <none> <none>
kube-flannel-ds-r8nnb 1/1 Running 1 (7d4h ago) 11d 2002:db8:0:1::102 2002.0db8.0000.0001.0000.0000.0000.0102 <none> <none>
kube-flannel-ds-rlnlb 1/1 Running 1 (7d4h ago) 11d 2002:db8:0:1::103 2002.0db8.0000.0001.0000.0000.0000.0103 <none> <none>
kube-state-metrics-5d47d4878c-cf974 1/1 Running 1 (7d4h ago) 11d 2001:cafe:42:2::19 2002.0db8.0000.0001.0000.0000.0000.0103 <none> <none>
node-local-dns-92td8 1/1 Running 9 (3d ago) 11d 2002:db8:0:1::101 2002.0db8.0000.0001.0000.0000.0000.0101 <none> <none>
node-local-dns-c7tq6 1/1 Running 2 (7d4h ago) 11d 2002:db8:0:1::102 2002.0db8.0000.0001.0000.0000.0000.0102 <none> <none>
node-local-dns-r6g5f 1/1 Running 2 (7d4h ago) 11d 2002:db8:0:1::103 2002.0db8.0000.0001.0000.0000.0000.0103 <none> <none>

测试跨节点通信:

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
$ ping6 -c 2 2001:cafe:42:2::1a
PING 2001:cafe:42:2::1a(2001:cafe:42:2::1a) 56 data bytes
64 bytes from 2001:cafe:42:2::1a: icmp_seq=1 ttl=63 time=0.178 ms
64 bytes from 2001:cafe:42:2::1a: icmp_seq=2 ttl=63 time=0.132 ms

--- 2001:cafe:42:2::1a ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1027ms
rtt min/avg/max/mdev = 0.132/0.155/0.178/0.023 ms

# 域名解析
$ dig @2001:cafe:42:2::1a kubernetes.default.svc.cluster.local +short AAAA
2001:cafe:43::1

$ kubectl -n default describe svc kubernetes
Name: kubernetes
Namespace: default
Labels: component=apiserver
provider=kubernetes
Annotations: <none>
Selector: <none>
Type: ClusterIP
IP Family Policy: SingleStack
IP Families: IPv6
IP: 2001:cafe:43::1
IPs: 2001:cafe:43::1
Port: https 443/TCP
TargetPort: 6443/TCP
Endpoints: [2002:db8:0:1::101]:6443,[2002:db8:0:1::102]:6443,[2002:db8:0:1::103]:6443
Session Affinity: None
Events: <none>

发现 nodelocaldns 有 Add ipv6 support to node-local-dns 支持,之前 IPv4 nodelocaldns 是使用 IPv4 的 Link-local address169.254.20.10 ,然后参照 pr 里面的 IPv6 fe80:169:254::1 发现报错:

1
Error listening: listen tcp6 [fe80:169:254::1]:8080: bind: invalid argument

最后怀疑不是 nodelocaldns 问题,写了个小 demo 验证:

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
package main

import (
"fmt"
"net"
"os"
)

func main() {
serverIP := os.Args[1]
serverPort := 8080

// 监听地址和端口
listener, err := net.Listen("tcp6", fmt.Sprintf("[%s]:%d", serverIP, serverPort))
if err != nil {
fmt.Println("Error listening:", err.Error())
return
}
defer listener.Close()

fmt.Printf("Listening on [%s]:%d\n", serverIP, serverPort)

for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err.Error())
return
}
go handleConnection(conn)
}
}

func handleConnection(conn net.Conn) {
defer conn.Close()
fmt.Println("Connection accepted.")
}

发现无法 bind fe80 开头报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ ip addr add ee80:169:254::1/128 dev lo
$ ip addr add fe80:169:254::1/128 dev lo
$ go run main.go ee80:169:254::1
Listening on [ee80:169:254::1]:8080
^Csignal: interrupt
$ go run main.go fe80:169:254::1
Error listening: listen tcp6 [fe80:169:254::1]:8080: bind: invalid argument
[root@guan ~/test/gotest]# uname -a
Linux guan 5.4.0-182-generic #202-Ubuntu SMP Fri Apr 26 12:29:36 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
[root@guan ~/test/gotest]# ip -6 a s lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
inet6 ee80:169:254::1/128 scope global
valid_lft forever preferred_lft forever
inet6 fe80:169:254::1/128 scope link
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever

调试发现最终是 syscall 报错,使用 python 写类似代码也一样,然发现后这个网段 fe80::/10 只能指定网卡名或者 index:

1
2
3
4
5
6
7
$ go run main.go fe80:169:254::1%lo
Listening on [fe80:169:254::1%lo]:8080
# 其他窗口 curl,带网卡名才行
$ curl -g [fe80:169:254::1%lo]:8080
curl: (56) Recv failure: Connection reset by peer
$ curl -g [fe80:169:254::1]:8080
curl: (7) Couldn't connect to server

中间件层面

大部分中间件都会自动判断而监听 IPv6 地址,但是有些会有问题。

elasticsearch

es 无法组件集群:

1
2
3
4
{"type": "server", "timestamp": "2024-07-23T10:59:43,648+08:00", "level": "WARN", "component": "o.e.d.HandshakingTransportAddressConnector", "cluster.name": "xxxes", 
"node.name": "es-host1.default", "message": "[connectToRemoteMasterNode[[2001:cafe:43::3250]:9300]] completed handshake with [{es-host3.default}{3e7q-VGnRmWdh1XRtvb0Ww}
{aCnHTNTLRp-rRVVg7N_z0Q}{127.0.0.1}{127.0.0.1:9300}{dm}{xpack.installed=true, transform.node=false}] but followup connection failed",
"stacktrace": ["org.elasticsearch.transport.ConnectTransportException: [es-host3.default][127.0.0.1:9300] handshake failed. unexpected remote node {es-host1.default}{n19UyOGnQpiErI0TW_jOPw}{6Wawn96FShGZyhYlo9BgDQ}{127.0.0.1}{127.0.0.1:9300}{dm}{xpack.installed=true, transform.node=false}",

看报错信息是其他几个 es 上报 IP 是 127.0.0.1 了,Pod 内的 lo 还是有 127.0.0.1 IP 的。参照官方文档 unicast.hostsmodules-network 尝试 network.publish_host: 设置 _global_ 也不行 _site_ ,最后设置的 network.publish_host: _eth0:ipv6_ 上报 IP 才正确。

应用层面

redis

最开始有个数据库初始化的 golang 应用,该应用(使用 github.com/redis/go-redis )需要连 redis 报错了:

1
redis setnx failed dial tcp: address 2002:db8:0:1::101:8531: too many colons in address

这个问题是代码里把 IPv6:Port 地址当成 IPv4:Port 去解析了,类似 Split(":") 不等于 2 而报错。但是研发反馈出错的地方是每次变化的,每个使用 redis 的地方都会随机出现,后面让他写了个最小 demo 源码发给我,我 dlv 远程调试了下

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
(dlv) so
> github.com/go-redis/redis.(*ClusterClient).defaultProcess() D:/Install/Go/GOPATH/pkg/mod/github.com/go-redis/redis@v6.15.9+incompatible/cluster.go:954 (PC: 0x7b9385)
Values returned:
moved: true
ask: false
addr: "2002:db8:0:1::103:8533"

949: continue
950: }
951:
952: var moved bool
953: var addr string
=> 954: moved, ask, addr = internal.IsMovedError(err)
955: if moved || ask {
956: node, err = c.nodes.GetOrCreate(addr)
957: if err != nil {
958: break
959: }


(dlv) so
> github.com/go-redis/redis.(*baseClient).defaultProcess() D:/Install/Go/GOPATH/pkg/mod/github.com/go-redis/redis@v6.15.9+incompatible/redis.go:186 (PC: 0x7fe612)
Values returned:
~r0: *github.com/go-redis/redis/internal/pool.Conn nil
~r1: error(*net.OpError) *{
Op: "dial",
Net: "tcp",
Source: net.Addr nil,
Addr: net.Addr nil,
Err: error(*net.AddrError) *{
Err: "too many colons in address",
Addr: "2002:db8:0:1::103:8533",},}

181: for attempt := 0; attempt <= c.opt.MaxRetries; attempt++ {
182: if attempt > 0 {
183: time.Sleep(c.retryBackoff(attempt))
184: }
185:
=> 186: cn, err := c.getConn()
187: if err != nil {
188: cmd.setErr(err)
189: if internal.IsRetryableError(err, true) {
190: continue
191: }
(dlv)


(dlv) p c.opt
("*github.com/go-redis/redis.Options")(0xc0000d8240)
*github.com/go-redis/redis.Options {
Network: "tcp",
Addr: "[2002:db8:0:1::101]:8531",
...
(dlv) c
> github.com/go-redis/redis.(*baseClient).defaultProcess() D:/Install/Go/GOPATH/pkg/mod/github.com/go-redis/redis@v6.15.9+incompatible/redis.go:186 (hits goroutine(1):45 total:52) (PC: 0x7fe5f3)
181: for attempt := 0; attempt <= c.opt.MaxRetries; attempt++ {
182: if attempt > 0 {
183: time.Sleep(c.retryBackoff(attempt))
184: }
185:
=> 186: cn, err := c.getConn()
187: if err != nil {
188: cmd.setErr(err)
189: if internal.IsRetryableError(err, true) {
190: continue
191: }
(dlv) p c.opt
("*github.com/go-redis/redis.Options")(0xc0000d8300)
*github.com/go-redis/redis.Options {
Network: "tcp",
Addr: "2002:db8:0:1::103:8533",
Dialer: github.com/go-redis/redis.(*Options).init.func1,
OnConnect: nil,

找到大概问题,这里 addr 变成了没带方括号的,addr 只有一个地方 moved, ask, addr = internal.IsMovedError(err) 获取的,内部逻辑是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func IsMovedError(err error) (moved bool, ask bool, addr string) {
if !IsRedisError(err) {
return
}

s := err.Error()
if strings.HasPrefix(s, "MOVED ") {
moved = true
} else if strings.HasPrefix(s, "ASK ") {
ask = true
} else {
return
}

ind := strings.LastIndex(s, " ")
if ind == -1 {
return false, false, ""
}
addr = s[ind+1:]
return
}

就是 redis 返回的 MOVED 14443 2002:db8:0:1::103:8533 重定向字样里取 IP 地址,也就是一开始从 DSN 字符串有方括号是能识别成 IPv6 地址,但是后面操作 redis 发生重定向就使用这个 IP 去做连接,看 redis 最新源码 void clusterRedirectClient 发现 redis server 并不会返回 IPv6 地址加方括号:

1
2
3
4
5
// https://github.com/redis/redis/blob/89742a95dbd4e95f5112136e4bcff698195e205c/src/cluster.c#L1179
addReplyErrorSds(c,sdscatprintf(sdsempty(),
"-%s %d %s:%d",
(error_code == CLUSTER_REDIR_ASK) ? "ASK" : "MOVED",
hashslot, clusterNodePreferredEndpoint(n), port));

搜索 redis move ipv6 搜到了 issue redis-cli ipv6 issue on redis cluster,发现其他语言的 SDK 都是处理 MOVE 后的地址加判断,于是去看 github.com/redis/go-redis 最新版本 isMovedError 方法发现 4个月之前修复了:

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
// https://github.com/redis/go-redis/blob/00d98485f8357d016139ece133bd3272e7ff2526/error.go#L114/
func isMovedError(err error) (moved bool, ask bool, addr string) {
if !isRedisError(err) {
return
}

s := err.Error()
switch {
case strings.HasPrefix(s, "MOVED "):
moved = true
case strings.HasPrefix(s, "ASK "):
ask = true
default:
return
}

ind := strings.LastIndex(s, " ")
if ind == -1 {
return false, false, ""
}

addr = s[ind+1:]
addr = internal.GetAddr(addr) // <---- 这里是处理
return
}

让升级引用库版本大于等于 9.5.2 后解决。

mysql

1
dial tcp [2002:db8:0:1::101:3306]:3306: connect: no route to host

该问题是因为 IPv6 地址可以缩写,然后 :3306 在 DSN 字符串里:

1
2
[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]
[username[:password]@][tcp[(2002:db8:0:1::101:3306)]]/dbname[?param1=value1&...&paramN=valueN]

被业务的 mysql client SDK 解析当作 IPv6 主机位而连错 IP,可以参照前面 redis 结尾的函数先处理先 mysql IP 地址再传入 DSN 字符串里。

组网测试

随着后续应用开始介入,那个 windows 不支持多人同时登录非常难受,记得以前配置过,但是还有 windows/macOS 上 app 端,还要看 app 端能否使用 IPv6 连 server。就考虑有没有隧道的形式组网:

ipv6-on-ipv4-tunel

windows 自带的内置的最好了,不然省得后续给开发和测试写个安装文档,搜了下有个 Teredo 但是看了下只有 Client 部署,Server 的部署没有,搜到开源的 miredo,但是部署 server 后启动报错:

1
2
3
$ /usr/sbin/miredo-server -f -c /etc/miredo/miredo-server.conf
...
Teredo server UDP socket err: Server IPv4 address must be global unicast

报错说不是 IPv4 组播地址,但是主机位写 255 还是不行,代码里搜了下这个报错找到 校验逻辑。然后没用过这些 c 库看不懂逻辑,就换 headscale 得了。

双栈机器上:

  • 安装 headsacleip_prefix 关闭 IPv4 保留 IPv6 那个网段 fd7a:115c:a1e0::/48
  • 安装 derp + tailscale,这个 tailscale up 时候带上 --advertise-routes 2002:db8:0:1::/64
  • 然后开启这个的路由 headscale routes enable -r 1

后面发现 windows 上开了客户端后访问 101 发现来源 IP 是双栈机器的 IPv6 IP,因为 tailscale linux 客户端默认是开启了 SNAT,把双栈机器上的 snat 关了:

1
tailscale set --snat-subnet-routes=false

因为双栈机器是 IPv6 网关,所以这样没做 SNAT 后:

  • windows/MacOS/手机(fd7a:115c:a1e0::/48) 访问 2002:db8:0:1::/64 走本机路由到接口 tailscale ,会封包隧道发往到双栈机器上。
  • 然后双栈机器上路由 2002:db8:0:1::/64 到右侧的机器,右侧机器回包(fd7a:115c:a1e0::/48)走默认路由到双栈机器,再发到客户端。

然后测试了下发现偶尔会断:

1
2
3
4
$ ssh 2002:db8:0:1::101
ssh: connect to host 2002:db8:0:1::101 port 22: No route to host
$ ssh 2002:db8:0:1::101
root@2002:db8:0:1::101's password:

双栈机器上 ip -6 r s | grep 2002:db8 看路由表又没问题,怀疑是 2002:db8:0:1::/64 的偶尔走出去到物理交换机上了,然后给双栈机器增加了个网卡隔离开,把 IPv6 主机位 1 配置在该网卡上,测试下发现很稳定。

然后给研发和测试写了个安装 tailscale 客户端和接入文档,很方便。

CATALOG
  1. 1. 由来
  2. 2. 过程
    1. 2.1. 环境搭建
    2. 2.2. 一些知识
    3. 2.3. K8S
    4. 2.4. flannel
    5. 2.5. nodelocaldns 和 link local 问题
    6. 2.6. 中间件层面
      1. 2.6.1. elasticsearch
    7. 2.7. 应用层面
      1. 2.7.1. redis
      2. 2.7.2. mysql
    8. 2.8. 组网测试