聊聊TCP Keepalive、Netty和Docker

eshizhan發表於2021-08-06

聊聊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

相關文章