ネットワーク●●●○○

ソケット

Socket ・ そけっと

プログラムがネットワーク通信をするために OS が用意する通信の端点。bind・listen・accept で扱う。

✦ 30秒で試す — 航海接続を先に切った側だけが、しばらく後始末を背負う443番の窓口1つで、同時に通信できる客の数は?予想してみる

概要

ソケットは、アプリケーションがネットワーク通信をするときに OS が用意する「通信の端点」です。TCP/IP の再送やパケットの分割・組み立ては OS のカーネルが引き受け、プログラムはソケットという取っ手を通じて、ファイルを読み書きするのと同じ感覚でデータを送受信できます。相手の指定は IPアドレスとポート番号の組で行います。

Web サーバもデータベースのクライアントもブラウザも、ネットワークを使うプログラムは例外なく内部でソケットを開いています。ふだんは HTTP のライブラリなどに隠れて見えませんが、「Connection refused」「Address already in use」「Too many open files」といったエラーや、「接続数が上限に達する」「負荷が上がると接続に時間がかかる」といった運用上の問題は、ソケットの状態に翻訳すると見通しがよくなります。

なぜ生まれたか

TCP/IP が ARPANET の標準になった1983年ごろ、プロトコルの仕様はあっても「プログラムからそれをどう使うか」の共通の形はありませんでした。アプリケーションを書くたびに OS ごとの独自の仕組みを覚え、パケットの組み立てや再送のような低い層の処理を意識するのは現実的ではありません。

そこで、カリフォルニア大学バークレー校の UNIX(1983年の 4.2BSD)が、「通信の端点をファイルのように開いて read / write する」という API を導入しました。これがバークレーソケットです。UNIX にはもともと「あらゆるものをファイルとして扱う」という設計思想があり、ソケットはネットワークをその世界に取り込みました。ソケットを開くと、OS はファイルと同じ番号(ファイルディスクリプタ)を返し、同じ read / write のシステムコールで読み書きできます。この抽象は使いやすく、のちに POSIX として標準化され、Windows の Winsock も含めて、ほぼすべての OS と言語が同じ形を踏襲しています。bind・listen・accept・connect という語彙は、この40年以上前の API から来ています。

詳細

待ち受けのソケットと、通信のソケット

TCP のソケットには、待ち受ける側(サーバ)と接続しに行く側(クライアント)で役割の違いがあります。サーバは、ソケットを作り(socket)、自分の IP アドレスとポート番号に結びつけ(bind)、待ち受けを始めます(listen)。クライアントが接続してくる(connect)と、サーバは accept を呼ぶたびに「その相手専用の通信用ソケット」を新しく受け取ります。

サーバのアプリサーバのカーネルクライアントサーバのアプリサーバのカーネルクライアント成立した接続を accept キューに置くsocket・bind・listen で待ち受け開始connect で SYNSYN+ACKACK で接続が成立accept新しい通信用ソケット fd 5write でリクエストwrite で応答close

大事なのは、3ウェイハンドシェイクを進めるのはアプリではなくカーネルだという点です。アプリが accept を呼ぶ前に、接続はすでに成立しています。accept は「成立済みの接続を1本、キューから受け取る」操作です。

待ち受けのソケットは1つのまま、accept のたびに通信用のソケットが増えていくので、1つのポートで同時に何千ものクライアントと通信できます。「1ポート1接続」ではありません。カーネルは届いたパケットを、送信元IP・送信元ポート・宛先IP・宛先ポートの4つ組で、どの通信用ソケットのものかを見分けます。

クライアントAクライアントBクライアントCサーバ 203.0.113.5カーネル待ち受けソケット:443 LISTEN・fd 3SYN キューaccept キュー上限が listen の backlogアプリ(accept で受け取る)fd 4 ↔ A198.51.100.7:51234 ↔ :443fd 5 ↔ B198.51.100.9:40211 ↔ :443fd 6 ↔ C192.0.2.30:33108 ↔ :443届いたパケットは 4つ組(送信元IP・送信元ポート・宛先IP・宛先ポート)で、どの fd のものかが決まる
1つの待ち受けソケットから、accept のたびに通信用ソケットが枝分かれする。どれも同じ :443 を使う

listen のバックログ — 2つの待ち行列

listen には「バックログ」という引数があり、これが待ち行列の長さを決めます。Linux のカーネルは、接続を2段の待ち行列で扱います。SYN を受け取って SYN+ACK を返し、最後の ACK を待っている「半開き」の接続は SYN キューに、ハンドシェイクが終わってアプリの accept を待っている接続は accept キューに入ります。バックログは主に accept キューの上限で、Linux では net.core.somaxconn(カーネル 5.4 以降の既定は4096、それ以前は128)で頭打ちになります。

accept キューがいっぱいになると、Linux は既定では新しい接続を断らずに黙って捨てます。クライアントは SYN を1秒後、さらに数秒後と再送するので、アプリが詰まっている間は「接続に1秒・3秒かかる」という不思議な遅れとして現れます。アプリが accept を呼ぶペースが到着に追いつかないことが原因なので、バックログを増やすだけでなく、accept したあとの処理を速くするか、処理するプロセスやスレッドを増やす必要があります。大量の SYN を送りつけて SYN キューをあふれさせる攻撃は SYN フラッドと呼ばれる DDoS攻撃の古典で、SYN キューに状態を持たずに済ませる SYN クッキーという対策があります。

下の部品で確かめてください。クライアントを押して次々に接続させると、待ち受けソケットはそのままで、accept のたびに通信用のソケットが増えていきます。サーバ側から接続を閉じてからサーバを再起動すると、何が起きるかも試せます。

⚡ 体験: 待ち受けソケット1つから、接続ごとのソケットが生まれる
アプリ
サーバ 203.0.113.5accept キュー(backlog 3)待ち受けソケット:443 LISTEN・fd 3アプリ: accept() を 0.7秒ごと通信用ソケットはまだありませんクライアントA押すと接続クライアントB押すと接続クライアントC押すと接続クライアントD押すと接続クライアントE押すと接続
SO_REUSEADDR

ESTABLISHED 0本・TIME_WAIT 0本(TIME_WAIT の 60秒は 8倍速で進みます)

左のクライアントを押して、サーバの :443 に接続させてください。何人つないでも、待ち受けソケットは1つのままです。

大量の接続をさばく — 多重化

古典的なサーバは、接続ごとにプロセスやスレッドを割り当てていました。しかし接続数が1万を超えるあたりで、生成のコストとメモリが破綻します(1999年に提起された C10K 問題)。現代のサーバは、epoll(Linux)や kqueue(BSD・macOS)といった OS の仕組みで「多数のソケットを1つのループで見張り、読み書きできるようになったものだけ処理する」イベント駆動の方式を採ります。Node.js のイベントループや Nginx が少ないスレッドで大量の同時接続に強いのは、このソケットの多重化のおかげです。

種類とバリエーション

ここまで述べたのは TCP に対応するストリームソケットですが、UDP に対応するデータグラムソケットもあります。こちらは connect や accept を経ずに、1つのソケットで不特定多数と送受信できます。同じマシンの中のプロセス同士が通信する UNIX ドメインソケットは、ネットワークの層を通らないぶん速く、Docker デーモン(コンテナを管理する常駐プログラム)の /var/run/docker.sock や、RDBMS の PostgreSQL へのローカル接続で広く使われています。なお WebSocket は名前が似ていますが、ブラウザ向けに HTTP の上で双方向通信を実現する別の(より上位の)プロトコルです。

運用のエラーをソケットに翻訳する

実務のトラブルの多くは、ソケットの状態に翻訳できます。「Connection refused」は、宛先のポートで listen しているソケットがなく、相手のカーネルが RST で断った状態です。「Connection timed out」は、SYN への返事がそもそも来ない状態で、ファイアウォールが黙って捨てているか、相手のaccept キューがあふれていることが多いです(タイムアウト)。「Address already in use」は、同じアドレスとポートにすでに使われているソケットがある状態です。

サーバの再起動直後に「Address already in use」が出るのは、閉じたばかりの接続が TIME_WAIT という残留状態でポートを握っているためです。TCP では、先に close した側が、遅れて届くパケットを新しい接続と取り違えないように、しばらく(Linux では60秒)その接続の記録を残します。サーバ側から接続を閉じることの多い Web サーバでは、TIME_WAIT がサーバ側にたまります。待ち受けソケットに SO_REUSEADDR というオプションを付けると、TIME_WAIT が残っていても bind できるので、多くのサーバの実装は既定でこれを付けています。

また、ソケットは OS から見るとファイルディスクリプタの一種なので、プロセスごとの上限(ulimit)を使い切ると「Too many open files」で新しい接続を受けられなくなります。接続を毎回張り直すとハンドシェイクの往復と TIME_WAIT が積み重なるので、データベースや HTTP のクライアントでは、確立済みのソケットを使い回すコネクションプールや keep-alive が定石です。

試問

サーバは :443 の待ち受けソケットで接続を受け、受け付けるたびに通信用のソケット(fd 4, 5, …)を作ります。左のクライアントを押すと接続し、つながったクライアントをもう一度押すとクライアント側から、通信用ソケットを押すとサーバ側から接続を閉じます。閉じた接続の記録(TIME_WAIT)は60秒ほど残り、この画面では8倍速で進みます。「サーバを再起動」では、:443 をもう一度確保(bind)し直します。

✦ 命題挑戦中

3本の接続を閉じた直後に、SO_REUSEADDR を付けずにサーバを再起動して、bind を成功させよ

  • 接続を3本つないでから閉じる(閉じた本数 0/3)
  • 閉じた直後(TIME_WAIT の 60秒が過ぎる前)に、SO_REUSEADDR なしで再起動する
  • その再起動で bind に成功する
⚡ 体験: 待ち受けソケット1つから、接続ごとのソケットが生まれる
アプリ
サーバ 203.0.113.5accept キュー(backlog 3)待ち受けソケット:443 LISTEN・fd 3アプリ: accept() を 0.7秒ごと通信用ソケットはまだありませんクライアントA押すと接続クライアントB押すと接続クライアントC押すと接続クライアントD押すと接続クライアントE押すと接続
SO_REUSEADDR

ESTABLISHED 0本・TIME_WAIT 0本(TIME_WAIT の 60秒は 8倍速で進みます)

左のクライアントを押して、サーバの :443 に接続させてください。何人つないでも、待ち受けソケットは1つのままです。