讓人迷糊的 socket udp 連接問題

公司內部的一個 golang 中間件報 UDP 連接異常的日誌,問題很明顯,對端的服務掛了,自然重啓下就可以了。

哈哈,但讓我疑惑的問題是 udp 是如何檢測對端掛了?

err:  write udp 172.16.44.62:62651->172.16.0.46:29999: write: connection refused

err:  write udp 172.16.44.62:62651->172.16.0.46:29999: write: connection refused

err:  write udp 172.16.44.62:62651->172.16.0.46:29999: write: connection refused

...

UDP 協議既沒有三次握手,又沒有 TCP 那樣的狀態控制報文,那麼如何判定對端的 UDP 端口是否已打開?

通過抓包可以發現,當服務端的端口沒有打開時,服務端的系統向客戶端返回 icmp ECONNREFUSED 報文,表明該連接異常。

通過抓包可以發現返回的協議爲 ICMP,但含有源端口和目的端口,客戶端系統解析該報文時,通過五元組找到對應的 socket,並 errno 返回異常錯誤,如果客戶端陷入等待,則喚醒起來,設置錯誤狀態.

(上面是 udp 異常下的 icmp,下面是正常 icmp)

當 UDP 連接異常時,可以通過 tcpdump 工具指定 ICMP 協議來抓取該異常報文,畢竟對方是通過 icmp 返回的 ECONNREFUSED。

使用 tcpdump 抓包

請求命令:

先找到一個可以 ping 通的主機,然後用 nc 模擬 udp 客戶端去請求不存在的端口,出現 Connection refused

[root@ocean ~]# nc -vzu 172.16.0.46 8888
Ncat: Version 7.50 ( https://nmap.org/ncat )
Ncat: Connected to 172.16.0.46:8888.
Ncat: Connection refused.

抓包信息如下:

[root@ocean ~]# tcpdump -i any icmp -nn
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked), capture size 262144 bytes
17:01:14.075617 IP 172.16.0.46 > 172.16.0.62: ICMP 172.16.0.46 udp port 8888 unreachable, length 37
17:01:17.326145 IP 172.16.0.46 > 172.16.0.62: ICMP 172.16.0.46 udp port 8888 unreachable, length 37
17:01:17.927480 IP 172.16.0.46 > 172.16.0.62: ICMP 172.16.0.46 udp port 8888 unreachable, length 37
17:01:18.489560 IP 172.16.0.46 > 172.16.0.62: ICMP 172.16.0.46 udp port 8888 unreachable, length 37

還需要注意的是 telnet 不支持 udp,只支持 tcp,建議使用 nc 來探測 udp。

各種 case 的測試

case 小結

IP 無法聯通時:

[root@host-46 ~ ]$ ping 172.16.0.65
PING 172.16.0.65 (172.16.0.65) 56(84) bytes of data.
From 172.16.0.46 icmp_seq=1 Destination Host Unreachable
From 172.16.0.46 icmp_seq=2 Destination Host Unreachable
From 172.16.0.46 icmp_seq=3 Destination Host Unreachable
From 172.16.0.46 icmp_seq=4 Destination Host Unreachable
From 172.16.0.46 icmp_seq=5 Destination Host Unreachable
From 172.16.0.46 icmp_seq=6 Destination Host Unreachable
^C
--- 172.16.0.65 ping statistics ---
6 packets transmitted, 0 received, +6 errors, 100% packet loss, time 4999ms
pipe 4

[root@host-46 ~ ]$ nc -vzu 172.16.0.65 8888
Ncat: Version 7.50 ( https://nmap.org/ncat )
Ncat: Connected to 172.16.0.65:8888.
Ncat: UDP packet sent successfully
Ncat: 1 bytes sent, 0 bytes received in 2.02 seconds.

另外再次明確一點 udp 沒有類似 tcp 那樣的狀態報文,所以單純對 UDP 抓包是看不到啥異常信息。

那麼當 IP 不通時,爲啥 NC UDP 命令顯示成功?

netcat nc udp 的邏輯

爲什麼當 ip 不連通或者報文被 DROP 時,返回連接成功?

因爲 nc 默認的探測邏輯很簡單,只要在 2 秒鐘內沒有收到 icmp ECONNREFUSED 異常報文,那麼就認爲 UDP 連接成功。😅

下面是 nc udp 命令執行的過程。

setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
connect(3, {sa_family=AF_INET, sin_port=htons(30000)sin_addr=inet_addr("172.16.0.111")}, 16) = 0
select(4, [3][3][3], NULL)          = 1 (out [3])
getsockopt(3, SOL_SOCKET, SO_ERROR, [0][4]) = 0
write(2, "Ncat: ", 6Ncat: )                   = 6
write(2, "Connected to 172.16.0.111:29999."..., 33Connected to 172.16.0.111:29999.
) = 33
sendto(3, "\0", 1, 0, NULL, 0)          = 1

// select 多路複用方法里加入了超時邏輯。
select(4, [3][][]{tv_sec=2, tv_usec=0}) = 0 (Timeout)

write(2, "Ncat: ", 6Ncat: )                   = 6
write(2, "UDP packet sent successfully\n", 29UDP packet sent successfully
) = 29
write(2, "Ncat: ", 6Ncat: )                   = 6
write(2, "1 bytes sent, 0 bytes received i"..., 481 bytes sent, 0 bytes received in 2.02 seconds.
) = 48
close(3)                                = 0

使用 golang/ python 編寫的 UDP 客戶端,給無法連通的地址發 UDP 報文時,其實也不會報錯,這時候通常會認爲發送成功。

還是那句話,UDP 沒有 TCP 那樣的握手步驟,像 TCP 發送 syn 總得不到回報時,協議棧會在時間退避下嘗試 6 次,當 6 次還得不到迴應,內核會給與錯誤的 errno 值。

UDP 連接信息

在客戶端的主機上,通過 ss lsof netstat 可以看到 UDP 五元組連接信息。

[root@host-46 ~ ]$ netstat -tunalp|grep 29999
udp        0      0 172.16.0.46:44136       172.16.0.46:29999       ESTABLISHED 1285966/cccc

通常在服務端上看不到 UDP 連接信息,只可以看到 udp listen 信息!

[root@host-62 ~ ]# netstat -tunalp|grep 29999
udp       0      0 :::29999                :::*                                4038720/ss

客戶端重新實例化問題?

當 client 跟 server 已連接,server 端手動重啓後,客戶端無需再次重新實例化連接,可以繼續發送數據,當服務端再次啓動後,照樣可以收到客戶端發來的報文。

udp 本就無握手的過程,他的 udp connect() 也只是在本地創建 socket 信息。在服務端使用 netstat 是看不到 udp 五元組的 socket。

Golang 測試代碼

服務端代碼:

package main

import (
    "fmt"
    "net"
)

// UDP 服務端
func main() {
    listen, err := net.ListenUDP("udp"&net.UDPAddr{
        IP:   net.IPv4(0, 0, 0, 0),
        Port: 29999,
    })

    if err != nil {
        fmt.Println("Listen failed, err: ", err)
        return
    }
    defer listen.Close()

    for {
        var data [1024]byte
        n, addr, err := listen.ReadFromUDP(data[:])
        if err != nil {
            fmt.Println("read udp failed, err: ", err)
            continue
        }
        fmt.Printf("data:%v addr:%v count:%v\n", string(data[:n]), addr, n)
    }
}

客戶端代碼:

package main

import (
    "fmt"
    "net"
    "time"
)

// UDP 客戶端
func main() {
    socket, err := net.DialUDP("udp", nil, &net.UDPAddr{
        IP:   net.IPv4(172, 16, 0, 46),
        Port: 29999,
    })
    if err != nil {
        fmt.Println("連接UDP服務器失敗,err: ", err)
        return
    }
    defer socket.Close()

    for {
        time.Sleep(1e9 * 2)
        sendData := []byte("Hello Server")
        _, err = socket.Write(sendData)
        if err != nil {
            fmt.Println("發送數據失敗,err: ", err)
            continue
        }

        fmt.Println("已發送")
    }
}

總結

當 udp 服務端的機器可以連通且無異常時,客戶端通常會顯示成功。但當有異常時,會有以下的情況:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9MhxlYkRKVCcZc5QdUqKBg