概要
サーバサイドの仕事をしているとソケットという概念に遭遇すると思います。
ソケットを理解すると
- TCPセッションの流れ
- ulimitでnofileを上げないとコネクション増加した時のToo many open filesが出るのはなぜか
- なぜサーバの待ち受けポートは1つで、クライアントのポートは接続するたびに新しいポートが必要なのか
- unix domain socketはなぜファイルパスを指定するのか
- Circusはなぜgraceful restartが可能なのか
といったことが分かるようになります。
今回そういったソケット周りの知識を理解するためにまとめます。
ファイルディスクリプタ
ソケットを扱う上で切り離せないのがファイルディスクリプタ(以下fd
)です。
fdとはファイルやソケットなどを抽象化した仕組みです。
ファイルディスクリプタという名称ですが、ファイルに限らず標準入出力、ソケット、ブロックデバイス、ディスプレイなど何でも「ファイルを扱うように」扱えます。
オブジェクト指向のポリモーフィズムの考え方ですね。
Go言語でいうとio.Reader
やio.Writer
に近いです。抽象化された入出力によって汎用的な使い方ができます。
fd番号
fdは単なる整数で、その数字で識別されます。fdはプロセス毎に管理されますが、そのうち0~2は予め決まっていて
- 0: 標準入力
- 1: 標準出力
- 2: 標準エラー出力
となってます。それ以降は新しくfdが必要となった時に小さい番号順で作られ、クローズされると同じ番号が再利用されます。
fdはどこに保持されてる?
fdはプロセス毎に管理されるため、/proc/プロセス番号/fd/
に保持されています。
また
$ lsof -p プロセス番号
で番号を確認できます。
ソケット
ソケットはネットワーク通信に用いる際のファイルディスクリプタです。
これはTCP/UDPのような外部ネットワークに繋がるインタフェースとしても使われますし、Unixドメインソケットのようなカーネル内部で完結するネットワークインタフェースとしても使われます。
TCPの場合のソケット通信の流れ
サーバにリクエストが来る際のソケットの使われ方を説明します。
- サーバ:listener用のソケットを1つ用意する
- サーバ:bind()システムコールを使って↑のlistenerに待ち受け用IPアドレスとTCPポートを設定する
- サーバ:accept()システムコールで接続要求を待つ(accept()がブロッキング。ノンブロッキング指定の場合は
EAGAIN
やEWOULDBLOCK
をハンドリングする) - クライアント:connect()システムコールでTCP SYNパケットを送る
- サーバ:接続要求のキューに↑が入る。accept()がSYNに応答し、SYN+ACKを返し、クライアントからACKが来るのを待つ
- クライアント:SYN+ACKを受け取り、ACK送る(connect()システムコールの完了)
- サーバ:accept()がACKを受け取る。accept()システムコールが完了し、戻り値にコネクション用の新しいTCPソケットを生成する
- コネクション(TCPセッション)が確立される
- クライアント:リクエスト送る
- サーバ:レスポンスを返す
- サーバ:TCPソケットをクローズする(closeシステムコール)
- クライアント: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が可能なのか
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)の仕様によるためです。
詳細を別途まとめました↓