Go 如何利用 Linux 內核的負載均衡能力
在測試 HTTP 服務時,如果該進程我們忘記關閉,而重新嘗試啓動一個新的服務進程,那麼將會遇到類似以下的錯誤信息:
$ go run main.go
listen tcp :8000: bind: address already in use
這是由於默認情況下,操作系統不允許我們打開具有相同源地址和端口的套接字 socket。但如果我們想開啓多個服務進程去監聽同一個端口,這可以嗎?如果可以,這又能給我們帶來什麼?
socket 五元組
socket 編程是每位程序員都應該掌握的基礎知識。因此,大家應該知道,socket 連接通過五元組唯一標識。任意兩條連接,它的五元組不能完全相同。
{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>}
protocol
指的是傳輸層 TCP/UDP 協議,它在 socket 被創建時就已經確定。src addr/port
與 dest addr/port
分別標識着請求方與服務方的地址信息。
因此,只要請求方的 dest addr/port
信息不相同,那麼服務方即使是同樣的 src addr/port
,它仍然可以標識唯一的 socket 連接。
基於這個理論基礎,那實際上,我們可以在同一個網絡主機複用相同的 IP 地址和端口號。
Linux SO_REUSEPORT
爲了滿足複用端口的需求,Linux 3.9 內核引入了 SO_REUSEPORT
選項(實際在此之前有一個類似的選項 SO_REUSEADDR
,但它沒有做到真正的端口複用,詳細可見參考鏈接 1)。
SO_REUSEPORT
支持多個進程或者線程綁定到同一端口,用於提高服務器程序的性能。它的特性包含以下幾點:
-
允許多個套接字 bind 同一個 TCP/UDP 端口
-
每一個線程擁有自己的服務器套接字
-
在服務器套接字上沒有了鎖的競爭
-
內核層面實現負載均衡
-
安全層面,監聽同一個端口的套接字只能位於同一個用戶下(same effective UID)
有了 SO_RESUEPORT
後,每個進程可以 bind 相同的地址和端口,各自是獨立平等的。
讓多進程監聽同一個端口,各個進程中 accept socket fd
不一樣,有新連接建立時,內核只會調度一個進程來 accept
,並且保證調度的均衡性。
其工作示意圖如下
有了 SO_REUSEADDR
的支持,我們不僅可以創建多個具有相同 IP:PORT
的套接字能力,而且我們還得到了一種內核模式下的負載均衡能力。
Go 如何設置 SO_REUSEPORT
Linux 經典的設計哲學:一切皆文件。當然,socket 也不例外,它也是一種文件。
如果我們想在 Go 程序中,利用上 linux 的 SO_REUSEPORT
選項,那就需要有修改內核 socket 連接選項的接口,而這可以依賴於 golang.org/x/sys/unix
庫來實現,具體就在以下這個方法。
import “"golang.org/x/sys/unix"”
...
unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
因此,一個持有 SO_REUSEPORT
特性的完整 Go 服務代碼如下
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"syscall"
"golang.org/x/sys/unix"
)
var lc = net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var opErr error
if err := c.Control(func(fd uintptr) {
opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
}); err != nil {
return err
}
return opErr
},
}
func main() {
pid := os.Getpid()
l, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:8000")
if err != nil {
panic(err)
}
server := &http.Server{}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Client [%s] Received msg from Server PID: [%d] \n", r.RemoteAddr, pid)
})
fmt.Printf("Server with PID: [%d] is running \n", pid)
_ = server.Serve(l)
}
我們將其編譯爲 linux 可執行文件 main
$ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
在 linux 主機上開啓三個同時監聽 8000 端口的進程,我們可以看到三個服務進程的 PID 分別是 32687 、32691 和 32697。
~ $ ./main
Server with PID: [32687] is running
~ $ ./main
Server with PID: [32691] is running
~ $ ./main
Server with PID: [32697] is running
最後,通過 curl 命令,模擬多次 http 客戶端請求
~ $ for i in {1..20}; do curl localhost:8000; done
Client [127.0.0.1:56876] Received msg from Server PID: [32697]
Client [127.0.0.1:56880] Received msg from Server PID: [32687]
Client [127.0.0.1:56884] Received msg from Server PID: [32687]
Client [127.0.0.1:56888] Received msg from Server PID: [32687]
Client [127.0.0.1:56892] Received msg from Server PID: [32691]
Client [127.0.0.1:56896] Received msg from Server PID: [32697]
Client [127.0.0.1:56900] Received msg from Server PID: [32691]
Client [127.0.0.1:56904] Received msg from Server PID: [32691]
Client [127.0.0.1:56908] Received msg from Server PID: [32697]
Client [127.0.0.1:56912] Received msg from Server PID: [32697]
Client [127.0.0.1:56916] Received msg from Server PID: [32687]
Client [127.0.0.1:56920] Received msg from Server PID: [32691]
Client [127.0.0.1:56924] Received msg from Server PID: [32697]
Client [127.0.0.1:56928] Received msg from Server PID: [32697]
Client [127.0.0.1:56932] Received msg from Server PID: [32691]
Client [127.0.0.1:56936] Received msg from Server PID: [32697]
Client [127.0.0.1:56940] Received msg from Server PID: [32687]
Client [127.0.0.1:56944] Received msg from Server PID: [32691]
Client [127.0.0.1:56948] Received msg from Server PID: [32687]
Client [127.0.0.1:56952] Received msg from Server PID: [32697]
可以看到,20 個客戶端請求被均衡地打到了三個服務進程上。
總結
linux 內核自 3.9 提供的 SO_REUSEPORT
選項,可以讓多進程監聽同一個端口。
這種機制帶來了什麼:
-
提高服務器程序的吞吐性能:我們可以運行多個應用程序實例,充分利用多核 CPU 資源,避免出現單核在處理數據包,其他核卻閒着的問題。
-
內核級負載均衡:我們不需要在多個實例前面添加一層服務代理,因爲內核已經提供了簡單的負載均衡。
-
不停服更新:當我們需要更新服務時,可以啓動新的服務實例來接受請求,再優雅地關閉掉舊服務實例。
如果你們的 Go 項目,一到高峯期就有請求堆積問題,這個時候就可以考慮採用 SO_REUSEPORT
選項。
參考鏈接:
【1. How do SO_REUSEADDR and SO_REUSEPORT differ?】https://stackoverflow.com/questions/14388706/how-do-so-reuseaddr-and-so-reuseport-differ
【2. SO_REUSEPORT 性能測試】 http://www.blogjava.net/yongboy/archive/2015/02/12/422893.html
【3. linux socket man-page】https://man7.org/linux/man-pages/man7/socket.7.html
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/pDUtY0sHEUKHBvWo69j-Ig