CLOVER🍀

That was when it all began.

LinuxのTCP Keep-Aliveを確認する

これは、なにをしたくて書いたもの?

TCPのKeep-Aliveについて、なんとなく知ってはいたものの、自分でちゃんと確認したことがなかったので1度見てみようかなと
思いまして。

TCP Keep-Alive

Keep-Aliveという言葉は、その言葉が適用される文脈で変わったりしますが、今回はTCPのKeep-Aliveを対象とします。

TCPのKeep-Aliveは、アクティブなTCPソケットに対して、通信相手がまだ健在か確認するためのものです(Keep-Alive)。

Keep-Aliveについては、RFCが存在します。

RFC 1122 - Requirements for Internet Hosts - Communication Layers

Requirements for Internet Hosts -- Communication Layers

TCP通信時にTCPソケットにKeep-Aliveオプションを設定すると、以下のような動作になります。

  • 確立したソケットを使用して、「一定時間データが交換されなかった」場合に、Keep-Alive Probeを送信する
  • 通信先がACKで応答した場合
    • 通信先は正常に動作していることが確認できたため、また「一定時間データが交換されなかった」場合には再度Keep-Alive Probeを送信する
  • 通信先がRSTで応答した場合
    • 通信先のTCP接続が再起動した状態のため、ソケットをクローズする(ソケットのエラーとしてはECONNRESTとなる)
  • 通信先が応答しない場合
    • 「指定の回数」、「一定の時間おきに」Keep-Alive Probeを送ることを繰り返し、最後まで応答がなければ接続先がダウンしたと判断してソケットをクローズする

この「一定時間データが交換されなかった」とか「指定の回数」、「一定の時間おきに」とかいうのは、OSの設定で行います。

アプリケーションから見ると、「Keep-Aliveを使うかどうか」という目線で設定します。

たとえば、JavaのSocketクラスでは、setKeepAliveをtrueに設定するだけです。

https://docs.oracle.com/javase/jp/11/docs/api/java.base/java/net/Socket.html#setKeepAlive(boolean)

環境

今回の環境は、こちらです。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 18.04.4 LTS
Release:    18.04
Codename:   bionic


$ uname -srvmpio
Linux 4.15.0-88-generic #88-Ubuntu SMP Tue Feb 11 20:11:34 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

Ubuntu Linux 18.04 LTS、カーネルのバージョンは4.15.0-88です。

また、動作確認用のアプリケーションとしてPythonでプログラムを書くので、Pythonのバージョンも。

$ python3 -V
Python 3.6.9

このような環境を、クライアント用とサーバー用で以下のように用意します。

  • クライアント … 192.168.33.11
  • サーバー … 192.168.33.12

サンプルアプリケーション

動作確認用のアプリケーションは、Pythonで簡単なEcho Client/Serverを書きます。

18.1. socket --- 低水準ネットワークインターフェース — Python 3.6.12 ドキュメント

サーバー側。
server.py

import socket
import sys
import traceback
from datetime import datetime

args = sys.argv[1:]

host = "0.0.0.0"
port = int(args[0])
keep_alive_enable = args[1]

bind_address = (host, port)
backlog_size = 10
recv_size = 1024

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    if keep_alive_enable == "true":
        server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
    
    server_socket.bind(bind_address)
    server_socket.listen(backlog_size)

    print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Server startup")

    try:
        while True:
            client_socket, addr = server_socket.accept()

            print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] accept client")
        
            while True:
                data = client_socket.recv(recv_size)

                if not data:
                    break

                client_socket.send(b'Reply: ' + data)

                print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] receive / send message")

            print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] disconnect client")
    except KeyboardInterrupt:
        print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Server stop")
    except Exception:
        traceback.print_exc()

クライアント側。
client.py

import socket
import sys
import traceback
from datetime import datetime

args = sys.argv[1:]

host = args[0]
port = int(args[1])
keep_alive_enable = args[2]

server_address = (host, port)
recv_size = 1024

message = 'hoge'

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
    if keep_alive_enable == "true":
        client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

    client_socket.connect(server_address)

    print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] connect to Server")

    try:
        while True:
            message = input(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Send to Server> ")
    
            client_socket.send(message.encode('utf-8'))

            data = client_socket.recv(recv_size)

            print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] > {data.decode('utf-8')}")
    except KeyboardInterrupt:
        print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] disconnect")
    except Exception:
        traceback.print_exc()

停止は、Ctrl-Cで。

あくまでKeep-Aliveの動作確認用なので、それ以外の視点は簡略化しています。

サーバー側は起動引数としてリッスンするポート、Keep-Aliveの有効/無効を指定し、クライアント側は接続先のサーバーの
IPアドレスまたはホスト名、ポート、そしてKeep-Aliveの有効/無効を指定します。

クライアントもサーバーもそうなのですが、最後の引数に「true」と書くと、ソケットのSO_KEEPALIVEを有効にする感じですね。

    if keep_alive_enable == "true":
        server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

このプログラムを、以下の環境で動かします。

  • client.py … 192.168.33.11
  • server.py … 192.168.33.12

1度、確認してみましょう。サーバーを起動。

$ python3 server.py 8080 false
[2020-02-25 14:17:21] Server startup

クライアントからアクセス。

$ python3 client.py 192.168.33.12 8080 false
[2020-02-25 14:18:01] connect to Server
[2020-02-25 14:18:01] Send to Server> Hello
[2020-02-25 14:18:03] > Reply: Hello
[2020-02-25 14:18:03] Send to Server> World
[2020-02-25 14:18:05] > Reply: World

OKですね。

第3引数をtrueにすると、Keep-Aliveが有効になっていることも確認してみましょう。

$ strace python3 server.py 8080 true
〜省略〜

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
setsockopt(3, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("0.0.0.0")}, 16) = 0

〜省略〜

SO_KEEPALIVEを設定しているのが確認できました。

falseの場合だと、設定が行われません。

$ strace python3 server.py 8080 false
〜省略〜

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("0.0.0.0")}, 16) = 0

〜省略〜

ここまでで、準備は完了です。

Keep-Alive無効の状態で確認してみる

まずは、現在のKeep-Aliveの設定を確認してみましょう。

$ sudo sysctl -a 2>&1 | grep keepalive 
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200

意味は、以下を参照。

https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/Documentation/networking/ip-sysctl.txt?h=v4.15#n335

  • net.ipv4.tcp_keepalive_time … Keep-Aliveが有効な場合に、TCP Keep-Aliveメッセージを送る間隔(デフォルト2時間)
  • net.ipv4.tcp_keepalive_probes … 接続が壊れていると判断するまでに、何回TCP Keep-Alive Probeを送信するか(デフォルト9回)
  • net.ipv4.tcp_keepalive_intvl … どのくらいの頻度でProbeを送信するか。Probeの送信開始後、tcp_keepalive_probesで指定した回数経過しても応答しない接続を切断する(デフォルト75秒)

つまり、Keep-Aliveが有効な場合、デフォルトではデータの送受信がなくなってから2時間後にProbeを送り始め、75秒おきにProbeを
9回送って応答がなければ(約11分後)、接続が無効だと判断します。

Probeを送っている途中で応答があれば有効な接続と判断し、次にProbeを送信するのは再びデータの送受信がなくなってから2時間後
となります。

で、確認にはちょっと長いので、これを変更しましょう。

$ sudo sysctl -w net.ipv4.tcp_keepalive_time=15
$ sudo sysctl -w net.ipv4.tcp_keepalive_intvl=5

Keep-Alive Probeの送信開始までを15秒に、送信間隔を5秒にしました。送信回数は、9回のままにしたので、Probeを送信し始めてから
1分で接続が壊れていると判断されますね。

なお、OS全体の設定を変更しているので、あくまで簡単な確認用ですよ、と。

まずは、Keep-Aliveをオフにして起動します。

## サーバー
$ python3 server.py 8080 false

## クライアント
$ python3 client.py 192.168.33.12 8080 false

最初のメッセージくらいは送っておきます。

[2020-02-26 14:56:55] Send to Server> Hello
[2020-02-26 14:56:57] > Reply: Hello

この時、tcpdumpでパケットキャプチャして見ておきましょう。8080ポートへの通信と、ICMPを確認します。

## サーバー
$ sudo tcpdump -i any -n tcp port 8080 or icmp

## クライアント
$ sudo tcpdump -i any -n tcp port 8080 or icmp

まあ、15秒以上待っていても、特になにも起こりません。

ここで、1度サーバー側のネットワークインターフェースを止めて

$ sudo ip link set eth1 down

サーバーを止めます。

[2020-02-26 14:57:27] Server stop

ネットワークインターフェースも起動させます。

$ sudo ip link set eth1 up

サーバーにメッセージを送ると、エラーになります。

[2020-02-26 14:56:57] Send to Server> World
Traceback (most recent call last):
  File "client.py", line 31, in <module>
    data = client_socket.recv(recv_size)
ConnectionResetError: [Errno 104] Connection reset by peer

キャプチャしたパケットを見ると、サーバーよりRST(コネクションをリセット)が返ってきているようです。
※この確認は、Keep-Aliveとの対比ではあまり意味がなかったのですが…

14:57:49.059076 IP 192.168.33.11.51052 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207515892 ecr 3801127985], length 5: HTTP
14:57:49.059470 IP 192.168.33.12.8080 > 192.168.33.11.51052: Flags [R], seq 1375342637, win 0, length 0

次に、サーバー、クライアントをもう1度起動しなおして

## サーバー
$ python3 server.py 8080 false

## クライアント
$ python3 client.py 192.168.33.12 8080 false
[2020-02-26 14:58:37] connect to Server
[2020-02-26 14:58:37] Send to Server> Hello
[2020-02-26 14:58:40] > Reply: Hello

サーバー側のネットワークインターフェースを止めます。

$ sudo ip link set eth1 down

で、クラアントからメッセージを送ってみます。

[2020-02-26 14:58:40] Send to Server> World

当然ですが、応答が返らなくなります。

裏では、TCPの再送が続く状態になりますね。

14:58:53.773998 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207580606 ecr 3801230555], length 5: HTTP
14:58:53.979056 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207580811 ecr 3801230555], length 5: HTTP
14:58:54.187199 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207581020 ecr 3801230555], length 5: HTTP
14:58:54.615118 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207581447 ecr 3801230555], length 5: HTTP
14:58:55.447070 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207582279 ecr 3801230555], length 5: HTTP
14:58:57.111126 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207583943 ecr 3801230555], length 5: HTTP
14:59:00.503162 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207587335 ecr 3801230555], length 5: HTTP
14:59:07.159148 IP 192.168.33.11.51054 > 192.168.33.12.8080: Flags [P.], seq 6:11, ack 13, win 502, options [nop,nop,TS val 207593991 ecr 3801230555], length 5: HTTP

ここで、サーバー側のネットワークインターフェースを復活させると

$ sudo ip link set eth1 up

応答が返るようになります。

[2020-02-26 14:59:20] > Reply: World

とりあえず、こんな感じで確認してみました。

Keep-Aliveを有効にして確認してみる

では、Keep-Aliveを有効にして確認してみましょう。
※RSTをKeep-Aliveで検出するケースは、うまくいかなかったので諦めました…(仮想環境でやろうとすると、どうしてもFINが先に飛んでしまう…)

## サーバー
$ python3 server.py 8080 true

## クライアント
$ python3 client.py 192.168.33.12 8080 true

15秒待っていると、Probeが送信されます。

15:00:31.639132 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 1, win 502, options [nop,nop,TS val 207678470 ecr 3801326543], length 0
15:00:31.639438 IP 192.168.33.12.8080 > 192.168.33.11.51056: Flags [.], ack 1, win 510, options [nop,nop,TS val 3801341642 ecr 207663371], length 0
15:00:31.773543 IP 192.168.33.12.8080 > 192.168.33.11.51056: Flags [.], ack 1, win 510, options [nop,nop,TS val 3801341776 ecr 207663371], length 0
15:00:31.773585 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 1, win 502, options [nop,nop,TS val 207678605 ecr 3801341642], length 0
15:00:46.743228 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 1, win 502, options [nop,nop,TS val 207693574 ecr 3801341642], length 0
15:00:46.743785 IP 192.168.33.12.8080 > 192.168.33.11.51056: Flags [.], ack 1, win 510, options [nop,nop,TS val 3801356747 ecr 207678605], length 0
15:00:46.877749 IP 192.168.33.12.8080 > 192.168.33.11.51056: Flags [.], ack 1, win 510, options [nop,nop,TS val 3801356880 ecr 207678605], length 0
15:00:46.877807 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 1, win 502, options [nop,nop,TS val 207693709 ecr 3801356747], length 0
15:01:01.847169 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 1, win 502, options [nop,nop,TS val 207708678 ecr 3801356747], length 0
15:01:01.847709 IP 192.168.33.12.8080 > 192.168.33.11.51056: Flags [.], ack 1, win 510, options [nop,nop,TS val 3801371850 ecr 207693709], length 0
15:01:01.981696 IP 192.168.33.12.8080 > 192.168.33.11.51056: Flags [.], ack 1, win 510, options [nop,nop,TS val 3801371984 ecr 207693709], length 0
15:01:01.981732 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 1, win 502, options [nop,nop,TS val 207708812 ecr 3801371850], length 0

メッセージくらいは送っておきましょう。

[2020-02-26 15:00:16] Send to Server> Hello
[2020-02-26 15:01:29] > Reply: Hello

ではここで、サーバー側のネットワークインターフェースを止めます。

$ sudo ip link set eth1 down

しばらく待っていると、15秒後のProbe送信の後に、5秒おきにメッセージがクライアントから送られるところを見ることができます。
※ネットワークインターフェースを止めてしまったサーバー側のキャプチャは見れません…止めるのをクライアント側にすると、状況が逆転します

15:01:59.959172 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 13, win 502, options [nop,nop,TS val 207766789 ecr 3801414858], length 0
15:02:05.079128 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 13, win 502, options [nop,nop,TS val 207771909 ecr 3801414858], length 0
15:02:10.199125 IP 192.168.33.11.51056 > 192.168.33.12.8080: Flags [.], ack 13, win 502, options [nop,nop,TS val 207777029 ecr 3801414858], length 0
15:02:18.391203 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:02:23.511225 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:02:28.631092 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:02:33.751155 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:02:38.871122 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:02:43.991092 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:02:49.111155 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60

最初の2回はTCPメッセージ、残り7回はICMPメッセージ(計9回)ですね。繰り返してみたら、TCPメッセージ、ICMPメッセージの
回数が異なることもありました。

この後にクライアントからメッセージを送ろうとすると、接続がタイムアウトした、と言われます。「通信先が応答しない場合」の
ケースですね。

[2020-02-26 15:01:29] Send to Server> World
Traceback (most recent call last):
  File "client.py", line 29, in <module>
    client_socket.send(message.encode('utf-8'))
TimeoutError: [Errno 110] Connection timed out

ちなみに、この時にサーバー側もタイムアウトして終了しています。

Traceback (most recent call last):
  File "server.py", line 34, in <module>
    data = client_socket.recv(recv_size)
TimeoutError: [Errno 110] Connection timed out

この差は、単純にクライアント(コンソールの入力待ち)とサーバー(ソケットの受信待ち)の作り方が違うからですね…。

ここで、ネットワークインターフェースを起動して

$ sudo ip link set eth1 up

もう1度、サーバーとクライアントをKeep-Aliveを有効にして起動。

## サーバー
$ python3 server.py 8080 true

## クライアント
$ python3 client.py 192.168.33.12 8080 true

とりあえず、メッセージを送ってみて

[2020-02-26 15:13:13] Send to Server> Hello
[2020-02-26 15:13:15] > Reply: Hello

サーバー側のネットワークインターフェースを停止します。

$ sudo ip link set eth1 down

すると、Keep-Alive Probeが送信され始めます。

15:14:01.111089 IP 192.168.33.11.51066 > 192.168.33.12.8080: Flags [.], ack 13, win 502, options [nop,nop,TS val 208487933 ecr 3802136001], length 0
15:14:06.231105 IP 192.168.33.11.51066 > 192.168.33.12.8080: Flags [.], ack 13, win 502, options [nop,nop,TS val 208493053 ecr 3802136001], length 0
15:14:14.423128 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:14:19.543124 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:14:24.663183 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60
15:14:29.783153 IP 192.168.33.11 > 192.168.33.11: ICMP host 192.168.33.12 unreachable, length 60

9回Probeを送りきる前に、停止しておいたネットワークインターフェースを起動します。

$ sudo ip link set eth1 up

このあとは、接続が有効なままなので、通信を継続することができます。

関連するソースコード

このあたりでしょうか?

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/tcp_output.c#L3711

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/tcp_output.c#L3665

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/tcp_output.c#L3711

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/tcp_timer.c#L631

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/icmp.c#L576

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/icmp.c#L930

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/icmp.c#L957

https://github.com/torvalds/linux/blob/v4.15/net/ipv4/icmp.c#L957

「RFC」とか「1122」とか「keepalive」で探すとよさそうですね。

困ったこと

最初、ファイアーウォール(ufw)で通信を切ろうと思ったのですが、現在有効な接続が切れてくれなかったです…。

また、今回のやり方ではKeep-AliveのフローでRSTパケットを見ることがどうにもうまくいかなかったので、ここは残念です。

まとめ

確認がちょっと中途半端になってしまったのですが、LinuxでのTCP Keep-Aliveの設定と、その動作をある程度見ることができました。

実際に触ってみると、いろいろ勉強になりますね…。

参考にしたのは、こちらの書籍です。