SSHのconnect()の先をeBPFで覗いてみた

SSHのconnect()の先をeBPFで覗いてみた

eBPFを使ってSSH接続のカーネル内部を観測してみました。前回の記事ではstraceでシステムコール全体を見ましたが、今回はbpftraceとBCCで、TCP接続がカーネル内でどのような状態遷移を経ているのかを確認します。接続成功・拒否・タイムアウト時の違いも一緒に見ていきます。
2026.07.26

こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。

前回はstraceを使い、sshコマンドがLinuxカーネルへ発行するシステムコールを観測しました。ソケットは、アプリケーションがネットワーク通信に使う入口です。sshが非ブロッキングに設定したソケットでは、接続がすぐに完了しないとconnect()EINPROGRESS(接続処理がまだ完了していないことを示す値)を返します。その後のppoll()getsockopt(SO_ERROR)で成否を確認していることが分かりました。

ただ、これで分かったのは「sshプロセスがLinuxカーネルに何を依頼したか」までです。connect()を受け取ったLinuxカーネルの中で、TCP接続がどの状態を経たのかまでは、straceでは見えません。

そこで今回は、eBPF(bpftrace/BCC)を使い、connect()の先、カーネル内部のTCP状態遷移を観測してみます。

前回の記事: straceでSSHコマンドの内部を覗いてみた

eBPF・bpftrace・BCCとは

   ssh

System Call   ← 前回、straceで見ていた場所

Linux Kernel
    │  └─ TCPの状態遷移   ← 今回、eBPFで見る場所

   NIC

  Server

eBPFは、カーネルのソースコードを変更したりカーネルモジュールを読み込んだりせずに、検証器のチェックを通ったプログラムをカーネル内で実行できる技術です。実行には権限が必要であり、どのようなプログラムでも無条件に安全に実行できるわけではありません。

出典: What is eBPF? - eBPF公式サイト

"It is used to safely and efficiently extend the capabilities of the kernel without requiring to change kernel source code or load kernel modules."
(カーネルのソースコードを変更したりカーネルモジュールを読み込んだりすることなく、カーネルの機能を安全かつ効率的に拡張するために使われる)

bpftraceBCC(BPF Compiler Collection)は、どちらもこのeBPFのプログラムを書きやすくするためのツールです。bpftraceはDTraceやSystemTapに似た専用の言語を持ち、短いスクリプトで書けます。

出典: bpftrace - bpftrace/bpftrace

"bpftrace is a general purpose tracing tool and language for Linux. It leverages eBPF... The bpftrace language is inspired by awk, C, and predecessor tracers such as DTrace and SystemTap."
(bpftraceはLinux向けの汎用トレーシングツール兼言語で、eBPFを利用している。bpftraceの言語はawkやC、DTraceやSystemTapといった先行するトレーサーから着想を得ている)

BCCは、PythonなどからBPFプログラムを作成・読み込みしやすくするツールキットです。今回使うtcpconnect-bpfccもBCCに含まれるツールの1つです。

出典: bcc - iovisor/bcc

"BCC is a toolkit for creating efficient kernel tracing and manipulation programs... It makes use of extended BPF (Berkeley Packet Filters), formally known as eBPF"
(BCCは効率的なカーネルトレーシング・操作プログラムを作成するためのツールキットで、拡張BPF、正式にはeBPFと呼ばれる機能を利用している)

今回はbpftraceとBCCを使い、connect()の先でカーネルが実際に何をしていたのかを、TCPソケットの状態遷移として確認します。

strace では見えなかったところ

前回確認できたのは、connect()の返り値と、その後のシステムコールまでです。

connect(3, {sa_family=AF_INET6, ... "::1" ...}, 28) = -1 EINPROGRESS (Operation now in progress)
getsockopt(3, SOL_SOCKET, SO_ERROR, [ECONNREFUSED], [4]) = 0

connect()が呼ばれたことや、その結果が成功・拒否・タイムアウトのどれだったかは分かります。しかし、その間にTCPソケットがカーネル内部でどのような状態を経たのかは、システムコールの記録には出てきません。ここから先を、bpftraceとBCCで覗いていきます。

検証環境

前回と同じ考え方で、使い捨てのUbuntuコンテナ1つにクライアントもサーバーも同居させます。capabilityは、Linuxが特定の特権操作を個別に許可する仕組みです。次は、今回の環境で動作を確認した構成です。

docker run -d --name ssh-ebpf-demo \
  --cap-add BPF \
  --cap-add PERFMON \
  --cap-add SYS_RESOURCE \
  -v /sys/kernel/tracing:/sys/kernel/tracing:ro \
  ubuntu:24.04 sleep infinity
  • CAP_BPFCAP_PERFMONは、トレーシングBPFプログラムの読み込みに関係します。CAP_PERFMONは、perf_eventsによる計測の権限も与えます。
  • CAP_SYS_RESOURCEは、bpftraceが必要に応じてメモリロックなどのリソース上限を変更するために追加しています。tracepointへの接続そのものを許可するcapabilityではありません。
  • tracefsは、tracepointなどの計測情報を公開する仮想ファイルシステムです。/sys/kernel/tracingを読み取り専用でマウントし、今回の環境では動作しました。
  • -dsleep infinityでコンテナを起動したままにし、後続のコマンドをdocker execで実行できるようにします。

eBPFプログラムはcolima VMのカーネルへ接続されます。そのため、観測範囲はコンテナ内に限定されません。同じVM上の別プロセスが条件に一致した場合、そのイベントも記録されます。

動作確認したバージョンです。

ソフトウェア バージョン
コンテナOS Ubuntu 24.04
カーネル(colima VM) 6.8.0-117-generic
Docker Server 29.5.2
bpftrace 0.20.2
bpfcc-tools(BCC) 0.29.1
OpenSSH 9.6p1

この構成では、後述するbpftraceスクリプトをアタッチできました。さらに、SSHで127.0.0.1:22へ接続し、CLOSE → SYN_SENT → CLOSEを確認しています。

BCCを実行するための補足

tcpconnect-bpfccは、実行時にカーネル依存のBPFコードを処理します。BCC公式資料では、BPFプログラムのコンパイルに、対応するカーネルヘッダー(カーネルのC APIの定義)が必要になる場合があると説明されています。ヘッダーの準備方法はディストリビューションとカーネルに依存するため、エラーになった場合はBCCのインストール手順を確認してください。

コンテナの中にopenssh-serveropenssh-clientbpftracebpfcc-toolsを入れ、前回と同じく検証専用のユーザーsshdemoを作成し、使い捨てのed25519鍵を生成しました。ssh sshdemo@127.0.0.1で自分自身に接続する構成です。

tcpconnect-bpfccでTCP接続の開始を見る

最初に確認したいのは、「どのプロセスが、どこへTCP接続を始めたか」です。ここではBCCに含まれるtcpconnect-bpfccを使います。

同じ検証環境を操作するターミナルを2つ開きます。

  • 表示用ターミナルでは、tcpconnect-bpfccを起動し、接続開始の情報が表示されるのを待ちます。
  • 接続実行用ターミナルでは、sshを実行してTCP接続を発生させます。

接続を発生させると、その情報が表示用ターミナルへ表示されます。

まず表示用ターミナルで、tcpconnect-bpfccを起動します。

docker exec -it ssh-ebpf-demo tcpconnect-bpfcc

tcpconnect-bpfccは接続開始を待ち受けたままになります。続いて、接続実行用ターミナルからSSH接続を実行します。

docker exec ssh-ebpf-demo \
  ssh sshdemo@127.0.0.1 echo TCPCONNECT_RETEST

表示用ターミナルに次の結果が表示されました。

PID     COMM         IP SADDR            DADDR            DPORT
7019    ssh          4  127.0.0.1        127.0.0.1        22

straceで見たconnect(..., sin_addr=inet_addr("127.0.0.1"))と対応する能動接続を、カーネル側からも観測できました。プロセス名、PID、送信元・宛先アドレス、宛先ポートが一致しています。

tcpconnect-bpfccで分かったこと

この結果から、sshプロセスが127.0.0.1:22への接続を始めたことが分かります。一方、接続が成功したのか、途中でどの状態を経たのかは分かりません。

tcpconnect-bpfccは接続開始を素早く確認する入口として便利です。しかし今回知りたいのは、connect()の後に起きる状態変化です。このコマンドは状態遷移を表示しないため、次は表示内容を指定できるbpftraceスクリプトを使います。tcpconnect-bpfccCtrl+Cで停止します。

TCPソケットの状態遷移を観測する

ここからが本題です。Linuxカーネルには、ソケットの状態変更を記録するsock:inet_sock_set_stateというtracepointがあります。tracepointは、カーネルのソースコードにあらかじめ定義された静的な計測地点です。このtracepointは、TCPソケットの状態が変わると、変更前と変更後の状態をイベントとして通知します。

図のbpftraceは状態を変えません。カーネルが状態を変更し、tracepointがイベントを通知し、そのイベントを受け取ったプログラムが端末へ表示します。

docker exec ssh-ebpf-demo \
  bpftrace -lv 'tracepoint:sock:inet_sock_set_state'

実行結果です。

tracepoint:sock:inet_sock_set_state
    const void * skaddr
    int oldstate
    int newstate
    __u16 sport
    __u16 dport
    __u16 family
    __u16 protocol
    __u8 saddr[4]
    __u8 daddr[4]
    __u8 saddr_v6[16]
    __u8 daddr_v6[16]

oldstate/newstateは、tracepointではint型の状態番号として渡されます。今回のスクリプトはprotocol == 6でTCPに絞っているため、この番号をLinuxカーネルのTCP状態定義(include/net/tcp_states.h)と対応付けます。

enum {
	TCP_ESTABLISHED = 1,
	TCP_SYN_SENT,
	TCP_SYN_RECV,
	TCP_FIN_WAIT1,
	TCP_FIN_WAIT2,
	TCP_TIME_WAIT,
	TCP_CLOSE,
	TCP_CLOSE_WAIT,
	TCP_LAST_ACK,
	TCP_LISTEN,
	TCP_CLOSING,
	TCP_NEW_SYN_RECV,
	...
};

出典: torvalds/linux - include/net/tcp_states.h

後述する@stateマップは、この番号をキーとして状態名へ置き換えます。たとえばoldstate == 7CLOSEnewstate == 2SYN_SENTです。スクリプトには、ここで示した1から12までの状態を登録しています。

bpftraceスクリプトとは

これから作るssh-tcp-state.btは、bpftraceが読み取るテキスト形式のプログラムです。通常のアプリケーションやカーネルモジュールではありません。bpftraceコマンドにファイルを渡すと、実行中だけ指定したtracepointにプログラムがアタッチされます。

このファイルは、次の3つの部分でできています。

  1. BEGIN: TCP状態番号を状態名へ変換する表を用意する
  2. tracepoint:sock:inet_sock_set_state: TCPかつ22番ポートに関わる状態変更を受け取り、状態名へ変換して表示する
  3. END: 終了時に状態名の表を削除する

次に、このスクリプトをファイルへ保存して実行します。先ほどの表示用ターミナルから、コンテナのシェルへ入ります。

docker exec -it ssh-ebpf-demo bash

コンテナ内で、次の内容を/tmp/ssh-tcp-state.btへ保存します。以下のコマンドは、コードブロック全体をそのまま実行できます。

cat > /tmp/ssh-tcp-state.bt <<'EOF'
BEGIN
{
  @state[1] = "ESTABLISHED";
  @state[2] = "SYN_SENT";
  @state[3] = "SYN_RECV";
  @state[4] = "FIN_WAIT1";
  @state[5] = "FIN_WAIT2";
  @state[6] = "TIME_WAIT";
  @state[7] = "CLOSE";
  @state[8] = "CLOSE_WAIT";
  @state[9] = "LAST_ACK";
  @state[10] = "LISTEN";
  @state[11] = "CLOSING";
  @state[12] = "NEW_SYN_RECV";
}

tracepoint:sock:inet_sock_set_state
/args->protocol == 6 && (args->dport == 22 || args->sport == 22)/
{
  printf("%-16s pid=%-7d %-12s -> %-12s sport=%d dport=%d\n",
    comm, pid, @state[args->oldstate], @state[args->newstate],
    args->sport, args->dport);
}

END
{
  clear(@state);
}
EOF

inet_sock_set_stateはTCP以外のプロトコルでも使われるため、protocol == 6(TCP)も条件に加えています。ポート番号だけで絞るより、意図しないイベントを拾いにくくなります。

作成したスクリプトを、同じ表示用ターミナルで実行します。

bpftrace /tmp/ssh-tcp-state.bt

Attaching 3 probes...と表示されたら、状態遷移を記録する準備は完了です。表示用ターミナルは動かしたままにし、SSHコマンドは接続実行用ターミナルで実行します。終了するときは、表示用ターミナルでCtrl+Cを押します。

stracesshプロセス1つにアタッチし、そのプロセスが発行したシステムコールだけを記録します。一方このスクリプトはカーネルのtracepointにアタッチしているため、対象プロセスを問わず、22番ポートに関わるTCPソケットの状態変化であれば、クライアント側(ssh)とサーバー側(sshd)の両方を同時に記録できます。

接続成功時の状態遷移を見る

接続実行用ターミナルから、コンテナ内のSSHクライアントを実行します。

docker exec ssh-ebpf-demo \
  ssh sshdemo@127.0.0.1 echo STATE_RETEST_OK

表示用ターミナルに次の状態遷移が表示されました。

ssh              pid=7098    CLOSE        -> SYN_SENT     sport=0 dport=22
ssh              pid=7098    SYN_SENT     -> ESTABLISHED  sport=41338 dport=22
ssh              pid=7098    LISTEN       -> SYN_RECV     sport=22 dport=0
ssh              pid=7098    SYN_RECV     -> ESTABLISHED  sport=22 dport=41338
ssh              pid=7098    ESTABLISHED  -> FIN_WAIT1    sport=41338 dport=22
ssh              pid=7098    ESTABLISHED  -> CLOSE_WAIT   sport=22 dport=41338
sshd             pid=7100    CLOSE_WAIT   -> LAST_ACK     sport=22 dport=41338
sshd             pid=7100    FIN_WAIT1    -> FIN_WAIT2    sport=41338 dport=22
sshd             pid=7100    FIN_WAIT2    -> CLOSE        sport=41338 dport=22
sshd             pid=7100    LAST_ACK     -> CLOSE        sport=22 dport=41338

ログの先頭にあるCLOSE → SYN_SENTは、クライアント側で接続を始めたときの遷移です。ここではsport=0と表示されています。最初のCLOSEは、「今ここでソケットを閉じた」という操作ではありません。まだ接続が成立していない状態を表すTCP_CLOSEです。sshconnect()を呼ぶと、カーネルのTCPスタックがCLOSEからSYN_SENTへ変更し、接続を開始します。

この状態変更を記録するのが、前節で説明したinet_sock_set_state tracepointです。bpftraceスクリプトは、tracepointが渡した状態番号を名前へ置き換えて表示しているだけです。状態を変更しているのはbpftraceではなく、カーネルのTCPスタックです。

今回のLinux 6.8・IPv4の接続では、bind()していないソケットをSYN_SENTへ変更した後に送信元ポートを選びます。そのため、最初の行はsport=0で、次のSYN_SENT → ESTABLISHEDから送信元ポート41338が表示されています。41338がCLOSEから始まったことを示すログではありません。

出典: tcp_ipv4.c(Linux v6.8のtcp_v4_connect)- torvalds/linux

この出力をクライアント側とサーバー側に分けると、次のようになります。

クライアント側

sport=0:     CLOSE → SYN_SENT
sport=41338: SYN_SENT → ESTABLISHED → FIN_WAIT1 → FIN_WAIT2 → CLOSE

サーバー側(sport=22)

LISTEN → SYN_RECV → ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSE

同じ1回のSSH接続でも、能動的に閉じたクライアント側ではFIN_WAIT1FIN_WAIT2、受動的に閉じたサーバー側ではCLOSE_WAITLAST_ACKが観測されました。

今回のログからは、クライアントが接続を始めてESTABLISHEDに到達するまでの状態変化を確認できました。SYNやSYN/ACKなどのパケットまで確認したくなったら、tcpdumpも併せて見るとよさそうです。

comm/PIDは実行コンテキストを示す

スクリプトのcommpidは、tracepointのargsに含まれる値ではありません。bpftraceが、tracepoint発火時に実行中だったスレッドから取得する組み込み値です。

今回のログでは、先頭6件がsshのPID 7098、残り4件がsshdのPID 7100と分かれています。そのため、この閉じた検証環境では、接続処理のどの段階でsshsshdが実行されていたかを知る手掛かりになります。

ただし、PID 7098の行にもsport=22のサーバー側状態があり、PID 7100の行にもsport=41338のクライアント側状態があります。commやPIDだけをソケットの役割を示す普遍的な根拠にはせず、ここではsportdportでクライアント側・サーバー側を判断します。

出典: bpftrace Standard Library(comm)bpftrace Standard Library(pid)bpftrace Language(tracepoint arguments)

接続成功の観測から分かったこと

straceでは、connect()が始まり、後から接続成功を確認するまでのシステムコールを追えます。bpftraceでは、その間にクライアント側がSYN_SENTからESTABLISHEDへ進み、サーバー側にも対応する状態変化が起きたことを確認できました。

さらに、SSHコマンドの終了後に行われる接続の切断も観測できました。ただし、bpftraceの出力にはSSHプロトコルの認証や暗号化処理は現れません。今回の観測対象は、SSHが利用するTCPソケットの状態です。

接続拒否時の状態遷移を見る

sshdをコンテナ内だけで停止し、接続実行用ターミナルから接続を試しました。

docker exec ssh-ebpf-demo pkill sshd
docker exec ssh-ebpf-demo \
  ssh -o ConnectTimeout=3 sshdemo@127.0.0.1 echo SHOULD_FAIL

実行結果です。

ssh              pid=6740    CLOSE        -> SYN_SENT     sport=0 dport=22
ssh              pid=6740    SYN_SENT     -> CLOSE        sport=55048 dport=22

接続拒否の場合も、カーネル内部では接続動作が始まり、ESTABLISHEDに到達せずSYN_SENTからCLOSEへ遷移していました。同じコンテナ内の接続で待ち受けるソケットがないため、接続拒否を示すTCPのリセットパケット(RST)を受け取ります。strace側では、最終結果をECONNREFUSEDとして確認できます。

接続拒否の観測から分かったこと

straceECONNREFUSEDだけを見ると、接続処理がすぐ失敗したことまでしか分かりません。bpftraceを組み合わせると、一度はSYN_SENTへ進んだものの、ESTABLISHEDへ到達せず閉じたことが分かります。

一方、状態遷移だけでは、なぜSYN_SENTからCLOSEへ移ったのかを断定できません。エラーの種類はstrace、状態変化はbpftrace、RSTなどのパケットはtcpdumpで確認する、という役割分担になります。

到達不能でタイムアウトする場合を見る

最後に、応答しない接続先として、文書用に予約されている192.0.2.1へ接続します。この環境では接続がタイムアウトしましたが、経路やファイアウォールの設定によっては別のエラーになる場合があります。

表示用ターミナルでは、引き続きbpftraceスクリプトを動かしておきます。接続実行用ターミナルで次のコマンドを実行します。

docker exec ssh-ebpf-demo \
  ssh -o ConnectTimeout=3 sshdemo@192.0.2.1 echo SHOULD_TIMEOUT

SSHコマンドは3秒後にタイムアウトしました。

ssh: connect to host 192.0.2.1 port 22: Connection timed out

同時に、表示用ターミナルでは次の状態遷移が表示されました。

ssh              pid=342873  CLOSE        -> SYN_SENT     sport=0 dport=22
ssh              pid=342873  SYN_SENT     -> CLOSE        sport=55420 dport=22

到達不能の観測から分かったこと

接続試行中、ソケットはSYN_SENTのまま待機します。このスクリプトは状態が変わったときだけ出力するため、待機中には新しい行が表示されません。3秒後にsshがタイムアウトしてソケットを閉じると、SYN_SENT → CLOSEが表示されました。

ppoll()は、ソケットにイベントが届くか、指定時間が過ぎるまでカーネルに待機を依頼するシステムコールです。今回のstraceとbpftraceを並べると、次のようになりました。

結果 straceで見えた最終確認 bpftraceで見えた状態遷移
成功 connect() = 0 CLOSE → SYN_SENT → ESTABLISHED
接続拒否 connect() = -1 EINPROGRESSの直後にgetsockopt(SO_ERROR)ECONNREFUSED CLOSE → SYN_SENT → CLOSE
到達不能 connect() = -1 EINPROGRESSの後、ppoll(...)=0 (Timeout) CLOSE → SYN_SENT → CLOSE

接続拒否の今回のログには、ppoll()は現れませんでした。getsockopt(SO_ERROR)が返したECONNREFUSEDで失敗を確認しています。一方、到達不能では、3秒間待機したppoll()が戻り値0、つまり待機時間切れで終わりました。connect(2)poll(2)にも、非ブロッキングのconnect()の後は待機とSO_ERRORで成否を確認でき、ppoll()の戻り値0は時間切れを表すとあります。

拒否と到達不能では、今回どちらもCLOSE → SYN_SENT → CLOSEになりました。bpftraceが表示するのは状態変化であり、失敗理由や待機時間までは含まれません。失敗の種類は、straceの戻り値と併せて判断します。

まとめ

今回の検証では、stracesshプロセスがOSへ依頼したこととその結果を、eBPFでカーネル内のTCP状態遷移を確認できました。同じSSH接続でも、確認する場所によって見えるものが異なります。

接続拒否と到達不能が、今回どちらもSYN_SENT → CLOSEになったことも確認できました。このため、失敗理由を知りたいときはstrace、状態遷移を追いたいときはeBPFを使うなど、見たいものに応じて手段を選ぶ必要があると学べました。パケットまで確認したくなったら、tcpdumpを併用します。

身近なsshコマンドでも、層ごとに見えるものが異なると学べました。SSH接続の動きが気になったときの参考になれば嬉しいです。

参考

この記事をシェアする

関連記事