Carpe Diem

備忘録

Webサーバにおけるソケット周りの知識

概要

サーバサイドの仕事をしているとソケットという概念に遭遇すると思います。
ソケットを理解すると

  • TCPセッションの流れ
  • ulimitでnofileを上げないとコネクション増加した時のToo many open filesが出るのはなぜか
  • なぜサーバの待ち受けポートは1つで、クライアントのポートは接続するたびに新しいポートが必要なのか
  • unix domain socketはなぜファイルパスを指定するのか
  • Circusはなぜgraceful restartが可能なのか

といったことが分かるようになります。
今回そういったソケット周りの知識を理解するためにまとめます。

ファイルディスクリプタ

ソケットを扱う上で切り離せないのがファイルディスクリプタ(以下fd)です。
fdとはファイルやソケットなどを抽象化した仕組みです。
ファイルディスクリプタという名称ですが、ファイルに限らず標準入出力、ソケット、ブロックデバイス、ディスプレイなど何でも「ファイルを扱うように」扱えます。
オブジェクト指向ポリモーフィズムの考え方ですね。
Go言語でいうとio.Readerio.Writerに近いです。抽象化された入出力によって汎用的な使い方ができます。

fd番号

fdは単なる整数で、その数字で識別されます。fdはプロセス毎に管理されますが、そのうち0~2は予め決まっていて

となってます。それ以降は新しくfdが必要となった時に小さい番号順で作られ、クローズされると同じ番号が再利用されます。

fdはどこに保持されてる?

fdはプロセス毎に管理されるため、/proc/プロセス番号/fd/に保持されています。
また

$ lsof -p プロセス番号

で番号を確認できます。

ソケット

ソケットはネットワーク通信に用いる際のファイルディスクリプタです。
これはTCP/UDPのような外部ネットワークに繋がるインタフェースとしても使われますし、Unixドメインソケットのようなカーネル内部で完結するネットワークインタフェースとしても使われます。

TCPの場合のソケット通信の流れ

サーバにリクエストが来る際のソケットの使われ方を説明します。

  1. サーバ:listener用のソケットを1つ用意する
  2. サーバ:bind()システムコールを使って↑のlistenerに待ち受け用IPアドレスTCPポートを設定する
  3. サーバ:accept()システムコールで接続要求を待つ(accept()がブロッキング。ノンブロッキング指定の場合はEAGAINEWOULDBLOCKをハンドリングする)
  4. クライアント:connect()システムコールTCP SYNパケットを送る
  5. サーバ:接続要求のキューに↑が入る。accept()がSYNに応答し、SYN+ACKを返し、クライアントからACKが来るのを待つ
  6. クライアント:SYN+ACKを受け取り、ACK送る(connect()システムコールの完了)
  7. サーバ:accept()がACKを受け取る。accept()システムコールが完了し、戻り値にコネクション用の新しいTCPソケットを生成する
  8. コネクション(TCPセッション)が確立される
  9. クライアント:リクエスト送る
  10. サーバ:レスポンスを返す
  11. サーバ:TCPソケットをクローズする(closeシステムコール
  12. クライアント:TCPソケットをクローズする(closeシステムコール

こんな感じです。

疑問点

Too many open filesが出るのはなぜか

TCPのソケット通信のフローの途中であったようにコネクション毎にfdを生成するので、デフォルト値のままだったりすると接続が多い時にファイルディスクリプタが枯渇するという問題が生じるわけです。

なぜクライアントのポートは接続するたびに新しいポートが必要なのか

クライアントマシンからサーバに複数のコネクションを接続すると、クライアント側は複数のポートを使って接続しているのにサーバはポートを1つしか使っていないことに疑問を覚えると思います。
この理由としてコネクション=TCPセッションは

  • 送信側IP
  • 送信側ポート
  • プロトコル
  • 受信側IP
  • 受信側ポート

の5つの組み合わせ(=5タプルと呼ぶ)で区別されます。
サーバ側のソケットはコネクション毎に別ですが、コネクション自体を識別するためにこの5タプルのうちどれかを変える必要があります。
サーバ側の待ち受けポートを変えることはできないので、クライアント側の新しいポートが使われる、ということです。

unix domain socketはなぜファイルパスを指定するのか

通常のTCPソケットではbind()システムコールによってIPアドレスとポートをソケットに設定し、識別することができます。
unix domain socketにはアドレスやポートが無いので、ファイルパスで識別するために明示的に指定する必要があります。

例えばGoで

   listener, err := net.Listen("unix", "./go.sock")

というunix domain socketのリスナーを用意すると

$ lsof -p 23805
COMMAND   PID   USER   FD     TYPE             DEVICE   SIZE/OFF       NODE NAME
unix    23805 a13156    0u     CHR               16,0    0t26746       1285 /dev/ttys000
unix    23805 a13156    1u     CHR               16,0    0t26746       1285 /dev/ttys000
unix    23805 a13156    2u     CHR               16,0    0t26746       1285 /dev/ttys000
unix    23805 a13156    3u    unix 0x56770374c2fcaf0d        0t0            ./go.sock
unix    23805 a13156    4u  KQUEUE                                          count=0, state=0xa

のようにNAMEにファイルパスが設定されてることが確認できます。

Circusはなぜgraceful restartが可能なのか

stackoverflow.com

fork(2)によって親プロセスのfdを子プロセスがまるっとコピーすることができます。
Circusではcircusdが親となってlistener用のfdを保持し、webappにfdのコピーを渡す形をとっています。
そしてデプロイ時には新たにfdのコピーを用意することで旧プロセスと新プロセスを同居させてgraceful restartが可能になります。

ちなみにfork(2)したfdを子プロセスがclose(2)しても親プロセスのfdには影響はありませんし、逆に親プロセスのfdをclose(2)しても子プロセスのfdはまだ使えます。これは

  • file descriptorが参照するopen file descriptionは同一
  • fdがopen file descriptionを参照する最期のファイルディスクリプタであった場合、open file descriptionに関連するリソースが解放される

というfork(2)close(2)の仕様によるためです。

詳細を別途まとめました↓

christina04.hatenablog.com

ソース