聊聊TCP Keepalive、Netty和Docker
本文主要闡述TCP Keepalive和對應的核心引數,及其在Netty,Docker中的實現。簡單總結了工作中遇到的問題,與大家共勉。
起因
之所以研究TCP Keepalive機制,主要是由於在專案中涉及TCP長連線。服務端接收客戶端請求後需要執行時間較長的任務,再將結果返回給客戶端。期間,客戶端和服務端沒有任何通訊,客服端持續等待服務端返回結果。
+-----------+ +-----------+
| | | |
| Client | | Server |
| | | |
| | Long Connection | |
| <---+--------------------+--> |
| | | |
+-----------+ +-----------+
那麼,問題來了,實際情況往往不會這麼簡單。在伺服器和客戶端之間往往還有眾多的網路裝置,其中一些網路裝置,由於特殊的原因,會導致上述的長連線無法維持較長時間,客戶端因此也無法獲得正確的結果。
典型的例子就是NAT或者防火牆,這類網路中介裝置都應用了一種叫連線跟蹤(connection tracking,conntrack)的技術,用來維護輸入和輸出的TCP連線資訊,使兩端裝置傳送的資料可達。但由於硬體上的瓶頸及基於效能的考慮,這類裝置不會維持所有的連線資訊,而是會將過期的不活躍的連線資訊踢出去。如果這時其中一方還在執行任務,沒有返回資料,造成這條連線徹底斷開,另一方永遠無法獲得資料。為了解決這一問題,引入了TCP Keepalive的技術。
+-----------+ +-----------+ +-----------+
| | | NAT OR | | |
| Client | | Firewall | | Server |
| | | | | |
| | Long Connection | drop | Long Connection | |
| <---+--------------------+--x x--+-------------------+--> |
| | | | | |
+-----------+ +-----------+ +-----------+
TCP Keepalive是什麼
其實理解起來非常簡單,就是在TCP層的心跳包。當客戶端與服務端之間的連線空閒了很長時間,期間沒有任何互動時,服務端或客戶端會傳送一個空資料的ACK探測包給對方,如果連線沒有問題,對方再以同樣的方式響應一個ACK包,如果網路有中斷ACK包會重複發多次直到上限。這樣TCP Keepalive就能解決兩個問題,其中之一是上述中使網路中介裝置保持該連線的活性,維持連線的狀態;另外,通過發包也可以探測雙方的程式存活狀態。Linux在核心中內建了對TCP Keepalive的支援,不過預設是關閉的,需要通過Socket選項SO_KEEPALIVE
開啟這個功能,這裡還涉及三個核心引數:
- tcp_keepalive_time:連線空閒的時長,預設7200秒。
- tcp_keepalive_probes:傳送ACK探測包的次數上限,預設9次。
- tcp_keepalive_intvl:傳送ACK探測包之間的間隔,預設75秒。
Client Server
| |
+----------------->|
| Last |
| Communicate |
|<-----------------+
| |
| |
| Long |
| |
| Idle Time |
| |
| |
|<-----------------+
| Keepalive ACK |
+----------------->|
| |
| |
Docker和核心引數
在應用層,當我們開啟了Socket SO_KEEPALIVE
選項,那麼Linux核心就會通過內建的定時器幫我們做好TCP Keepalive的相關工作。由於第一節描述的原因,現實中網路中介裝置NAT或防火牆往往都會把失活的判斷標準調低,也就是說判斷長連線活性的空閒時間會遠遠小於Linux核心鎖設定的7200秒,一般也就幾十分鐘甚至幾分鐘,這就需要我們調整將核心引數tcp_keepalive_time調低。最簡單的方式就是通過sysctl
介面,調整對應的引數:
sysctl -w net.ipv4.tcp_keepalive_time=300
但是這裡要留意的是,如果你的服務執行在Docker容器中,調整核心引數的方式會有所不同。
這是由於Docker會通過名稱空間(namespace)隔離不同的容器網路,而對應的核心引數也是被隔離的。當Docker在啟動容器的時候,建立的network名稱空間並不會從宿主機繼承大部分的核心網路引數,而是將這些引數設定為Linux核心編譯時指定的預設值。
因此我們必須通過--sysctl
引數,在Docker啟動容器時,將對應的核心引數初始化。
並不是所有的核心引數都支援名稱空間,我們從Docker的官方文件中,可以瞭解已支援的核心引數以及使用的限制:
IPC Namespace:
kernel.msgmax, kernel.msgmnb, kernel.msgmni, kernel.sem, kernel.shmall, kernel.shmmax, kernel.shmmni, kernel.shm_rmid_forced.
Sysctls beginning with fs.mqueue.*
If you use the --ipc=host option these sysctls are not allowed.Network Namespace:
Sysctls beginning with net.*
If you use the --network=host option using these sysctls are not allowed.
Netty中的Keepalive
在瞭解完TCP Keepalive的機制及Linux核心對其相關支援後,我們回到應用層,看看具體如何實現,以及另外推薦的解決方案。下面我拿Java的Netty舉例。Netty中直接提供了ChannelOption.SO_KEEPALIVE
選項,將其傳給ServerBootstrap.childOption
方法,即可開啟TCP Keepalive功能,配置好相關核心引數後,剩下的交給核心搞定。那麼,既然核心將TCP Keepalive引數暴露給使用者態,有沒有一種方法能在應用級別調整這些引數,而不用修改系統全域性的引數呢?通過man pages瞭解到,可以通過setsockopt方法為當前TCP Socket配置不同的TCP Keepalive引數,這些引數將會覆蓋系統全域性的。
通過調整每個Socket的Keepalive引數會更加靈活,不會因修改系統全域性引數而影響到其他應用。接下來看看如何通過Java 的Netty庫來設定對應的引數,Netty中預設的NIO transport沒有直接提供對應的Socket Option,除非使用了netty-transport-native-epoll (https://github.com/netty/netty/pull/2406)。而在JDK 11中新增了對這些引數的支援:
若想在Netty中使用,還需要做一層封裝。下面是對應的示例程式碼,僅供參考:
public void run() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new DiscardServerHandler());
}
})
// 配置TCP Keepalive引數,將Keepalive空閒時間設為150秒
.option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPIDLE), 150)
.option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPINTERVAL), 75)
.option(NioChannelOption.of(ExtendedSocketOptions.TCP_KEEPCOUNT), 9)
// 開啟SO_KEEPALIVE
.childOption(ChannelOption.SO_KEEPALIVE, true);
ChannelFuture f = b.bind(port).sync();
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
接下來,我們如何知道設定的引數已經起作用了呢?由於涉及TCP Keepalive機制內建在Linux核心,因此無法在應用級別debug,但可以通過一些其他手段對連線進行監測。其一是通過iproute2
提供的ss
命令的-o
選項檢視對應的Socket Options;其二,是通過tcpdump
抓包分析。
首先來看,預設不做任何改動時的情況:
接下來僅開啟SO_KEEPALIVE
:
可以看到Socket Options的keepalive定時器為119min,也就是反映出系統預設配置的空閒時間為7200秒。
最後,我們開啟SO_KEEPALIVE
,並且設定TCP_KEEPIDLE
引數為150秒:
可以看到上面tcpdump
抓包顯示出,兩次ACK包間隔為2分半,即150秒,包的length為0,這就是TCP Keepalive的ACK探測包。同時也可以看到下面ss
命令顯示Socket Options中keepalive timer定時器的倒數計時狀態。
總結
通過這篇文章,我們瞭解到:
- TCP Keepalive的概念、原理及其兩個重要作用。
- TCP Keepalive的三個系統核心引數,及其在Docker容器環境中的特殊配置方式。
- 通過Java的Netty庫演示如何開啟TCP Keepalive,探索在應用層靈活配置三個核心引數。
ref:
TCP Keepalive HOWTO
SO: tcp_keepalive_time in docker container
docker run Docs