SSHのconnect()の先をeBPFで覗いてみた
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
前回は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は、カーネルのソースコードを変更したりカーネルモジュールを読み込んだりせずに、検証器のチェックを通ったプログラムをカーネル内で実行できる技術です。実行には権限が必要であり、どのようなプログラムでも無条件に安全に実行できるわけではありません。
"It is used to safely and efficiently extend the capabilities of the kernel without requiring to change kernel source code or load kernel modules."
(カーネルのソースコードを変更したりカーネルモジュールを読み込んだりすることなく、カーネルの機能を安全かつ効率的に拡張するために使われる)
bpftraceとBCC(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 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_BPFとCAP_PERFMONは、トレーシングBPFプログラムの読み込みに関係します。CAP_PERFMONは、perf_eventsによる計測の権限も与えます。CAP_SYS_RESOURCEは、bpftraceが必要に応じてメモリロックなどのリソース上限を変更するために追加しています。tracepointへの接続そのものを許可するcapabilityではありません。- tracefsは、tracepointなどの計測情報を公開する仮想ファイルシステムです。
/sys/kernel/tracingを読み取り専用でマウントし、今回の環境では動作しました。 -dとsleep 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-server・openssh-client・bpftrace・bpfcc-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-bpfccはCtrl+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 == 7はCLOSE、newstate == 2はSYN_SENTです。スクリプトには、ここで示した1から12までの状態を登録しています。
bpftraceスクリプトとは
これから作るssh-tcp-state.btは、bpftraceが読み取るテキスト形式のプログラムです。通常のアプリケーションやカーネルモジュールではありません。bpftraceコマンドにファイルを渡すと、実行中だけ指定したtracepointにプログラムがアタッチされます。
このファイルは、次の3つの部分でできています。
BEGIN: TCP状態番号を状態名へ変換する表を用意するtracepoint:sock:inet_sock_set_state: TCPかつ22番ポートに関わる状態変更を受け取り、状態名へ変換して表示する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を押します。
straceはsshプロセス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です。sshがconnect()を呼ぶと、カーネルの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_WAIT1・FIN_WAIT2、受動的に閉じたサーバー側ではCLOSE_WAIT・LAST_ACKが観測されました。
今回のログからは、クライアントが接続を始めてESTABLISHEDに到達するまでの状態変化を確認できました。SYNやSYN/ACKなどのパケットまで確認したくなったら、tcpdumpも併せて見るとよさそうです。
comm/PIDは実行コンテキストを示す
スクリプトのcommとpidは、tracepointのargsに含まれる値ではありません。bpftraceが、tracepoint発火時に実行中だったスレッドから取得する組み込み値です。
今回のログでは、先頭6件がsshのPID 7098、残り4件がsshdのPID 7100と分かれています。そのため、この閉じた検証環境では、接続処理のどの段階でsshとsshdが実行されていたかを知る手掛かりになります。
ただし、PID 7098の行にもsport=22のサーバー側状態があり、PID 7100の行にもsport=41338のクライアント側状態があります。commやPIDだけをソケットの役割を示す普遍的な根拠にはせず、ここではsportとdportでクライアント側・サーバー側を判断します。
出典: 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として確認できます。
接続拒否の観測から分かったこと
straceのECONNREFUSEDだけを見ると、接続処理がすぐ失敗したことまでしか分かりません。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の戻り値と併せて判断します。
まとめ
今回の検証では、straceでsshプロセスがOSへ依頼したこととその結果を、eBPFでカーネル内のTCP状態遷移を確認できました。同じSSH接続でも、確認する場所によって見えるものが異なります。
接続拒否と到達不能が、今回どちらもSYN_SENT → CLOSEになったことも確認できました。このため、失敗理由を知りたいときはstrace、状態遷移を追いたいときはeBPFを使うなど、見たいものに応じて手段を選ぶ必要があると学べました。パケットまで確認したくなったら、tcpdumpを併用します。
身近なsshコマンドでも、層ごとに見えるものが異なると学べました。SSH接続の動きが気になったときの参考になれば嬉しいです。
参考
- What is eBPF? - eBPF公式サイト
- bpftrace - bpftrace/bpftrace
- bcc - iovisor/bcc
- Docker run reference(Linux capabilities)- Docker Docs
- capability.h(CAP_BPF/CAP_PERFMON)- torvalds/linux
- Perf events and tool security - Linux Kernel documentation
- BCC Reference Guide(カーネルヘッダー)- iovisor/bcc
- tcp_states.h - torvalds/linux
- tcp_ipv4.c(Linux v6.8のTCP_SYN_SENTへの状態変更)- torvalds/linux
- sock.h(inet_sock_set_state tracepoint)- torvalds/linux
- tcp_minisocks.c(TIME_WAIT処理)- torvalds/linux
- connect(2) - Linux man-pages
- RFC 9293 - Transmission Control Protocol (TCP)
- bpftrace(8) - bpftrace公式マニュアル
- bpftrace Reference Guide
- BCC tcpconnect - iovisor/bcc
- RFC 5737 - IPv4 Address Blocks Reserved for Documentation
- straceでSSHコマンドの内部を覗いてみた - DevelopersIO








