ネットワーク通信の裏側と Socket API
- Authors
- Name
- ごとれん
- X
- @ren510dev
目次
- 目次
- はじめに
- ネットワークソケットとは
- スタンダードソケット
- Raw ソケット
- 主要な Socket API
- socket()
- bind()
- listen()
- accept()
- connect()
- send() と recv()
- close()
- ソケットプログラミング
- 1. ソケットの作成
- 2. ソケットの設定
- 3. コネクションの確立
- 4. データの送受信
- 5. ソケットクローズ
- Socket API による echo サーバの実装
- Socket API の活用例
- まとめ
- 参考・引用
はじめに
インターネットを介して提供されるサービスはどのように構築・実現されるのでしょうか。
現代のネットワークアプリケーション開発において、Socket API は非常に重要な役割を果たします。 Socket API は、異なるコンピュータ間でデータを送受信するための低レベルインターフェースを提供し、TCP/IP に則った通信を実現します。
今回の記事では、ネットワークソケットについて触れ、Socket API の概要から実際の使用法について紹介します。
ネットワークソケットとは
ネットワークソケットとは、アプリケーションがインターネットを介してデータを送受信するための仕組みを抽象化したものです。 ネットワークインターフェース(NIC:Network Interface Card)や IP アドレス、ポート番号等、アプリケーションが通信をする際にコンピュータの出入り口となるものがソケットに該当します。
ソケット通信は、一般にサーバとクライアント間で用いられます。 Socket API(Application Programming Interface) と呼ばれる低レイベルインターフェースを使用することで、アプリケーションに対して異なるホスト間での通信をサポートするためのトランスポート層以下の機能を提供します。
ネットワークソケットを用いたクライアント・サーバ間におけるメッセージの送受信は以下の手続きに基づいて行われます。
- サーバ側
- ①
socket()
はソケット(FD)を作成 - ②
bind()
はソケットをローカルのアドレス(FD や IP アドレス + Port)にバインド - ③
listen()
でソケットに接続を待ち受けるように命令 - ④
accept()
で外部からの接続に対して新しいソケットを作成 - ⑤⑥
send()
,receive()
でデータの送受信を実行 - ⑦
close()
でソケットをクローズして FD も削除
- ①
- クライアント側
- ①
socket()
でソケット(FD)を作成 - ②
connect()
でサーバ側のソケットに接続 - ③④
send()
,receive()
でデータの送受信を実行 - ⑤
close()
でソケットをクローズして FD も削除
- ①
ここで、listen()
, connect()
, accept()
等が Socket API に該当します。
データの送受信は、アプリケーションが FD(File Descriptor) と呼ばれる記述子・ファイルを開き、内蔵ディスクに対してデータを読み書きするのと同じ原理で行われます。 アプリケーションはソケットを使ってネットワークに接続することで、同じくネットワークに接続されている別のアプリケーションと通信を行うことが可能となります。 一方のマシンでアプリケーションがソケットに書き込んだ情報を、相手のマシンで動作するアプリケーションが読み取ることで、インターネットを通じた相互通信が成立するわけです。
ソケットは大きく 2 種類 存在します。
まず、一般的に利用される TCP/IP プロトコルファミリを扱うストリームソケットとデータグラムソケットです。 これらは スタンダードソケット と呼ばれます。
また、既存のプロトコルを利用しつつ、新たなプロトコルを実装する際に利用される Raw ソケット というのが存在します。
以下に、スタンダードソケットと Raw ソケットについて紹介します。
スタンダードソケット
スタンダードソケットのうち、ストリームソケットは IP の上位層でエンドツーエンドプロトコルとして TCP を使用するため、信頼性の高いストリームサービスが利用できます。 また、データグラムソケットは IP の上位プロトコルとして UDP を使用するため、ベストエフォート型のデータグラムサービスを利用することができます。
スタンダードソケットの挙動によって TCP の正確さ や UDP の軽快さ といった特性を産みます。
TCP は コネクション指向型 であるため、相手を確認し、事前にコネクションを確立してから通信を開始するため、確実にメッセージが相手に届きます。 1 度の通信でエラーが生じてしまった場合は、接続できるまで繰り返し接続を試みるため、エラーを解消する時間は掛かりますが、確実に送信したメッセージを相手が受け取ることができます。
一方の UDP は コネクションレス型 であるため、通信相手を確認することなくメッセージを送信します。 そのため、送信されたメッセージが相手に確実に届くとは限りません。 しかし、再送制御に伴う手続きがない分高速に動作することが可能であり、データの欠落が許される範囲内で、大容量なメッセージを高速に送信したい場合等に利用されます。
以下にストリームソケットとスタンダードソケットの仕組みを示します。
ストリームソケットは確実な通信が可能な一方で、通信制御に伴うオーバーヘッドが大きくなるという特徴があります。 一方の、データグラムソケットは高速な通信を実現可能な上、最大で一度に約 65,500 バイトものメッセージを送信することができますが、データの到達性(データが欠落していないか)については保証されないという特徴があります。
スタンダードソケットにおける、ストリームソケットおよびデータグラムソケットは、いずれも送信するペイロードが、トランスポート層のプロトコル(TCP または UDP)でカプセル化されます。
【FYI】Linux カーネルにおけるネットワークスタックの実装
Raw ソケット
Raw ソケットは、通常、カーネル空間(アプリケーションが処理する前段)で処理されてしまう IP ヘッダや TCP/UDP ヘッダを含んだ Raw パケットを送受信します。
前述のスタンダードソケットと Raw ソケットが取り扱うデータの違いを図に示します。
Raw ソケットは、ユーザ空間で新たなプロトコル層を実装することが可能なため、通信プロトコルの開発や既存プロトコルにアクセスする際に使用されます。 Raw ソケットを使用するプログラムにおいて、生成したソケットからパケット操作を行う関数の動作は下図のようになっています。
Raw ソケットの場合、ソケットインターフェースは AF_PACKET
を指定することにより、変更が一切加えられていない受信直後の Raw パケットを、そのままユーザ空間のアプリケーションで受け取ることが可能となります。 ソケットオプションとして SOCK_DGRAM
を指定した場合、IP 層からアプリケーション層までのデータを一括で取得します。 また、SOCK_RAW
を指定した場合、ソケットは IEEE 802.3 フレームの IEEE 802.2 LLC(Logic Link Control)ヘッダの生成や解析を行うことが可能なため、データリンク層からデータの取得が可能となります。
アドレスファミリ | 説明 | 指定するアドレス |
---|---|---|
AF_UNIX | • ローカル通信に使用 • 同一マシン上で効率的なプロセス間通信を可能とする • Unix ドメインソケット通信 | ソケットファイルのファイルパス |
AF_INET | • IPv4 通信に使用 • TCP ソケット通信 | ホスト名, ポート番号 |
AF_INET6 | • IPv6 通信に使用 • TCP ソケット通信 | ホスト名, ポート番号 |
- socket(2) - Linux manual page
- address_families(7) - Linux manual page
- unix(7) - Linux manual page
- ip(7) - Linux manual page
- ipv6(7) - Linux manual page
github.com/torvalds/linux/include/linux/socket.h
struct sockaddr
{
sa_family_t sa_family; /* address family, AF_xxx */
union
{
char sa_data_min[14]; /* Minimum 14 bytes of protocol address */
DECLARE_FLEX_ARRAY(char, sa_data);
};
};
一方で、Raw ソケットを用いた場合にも、自身の MAC(Media Access Control)アドレス以外の宛先が指定されたパケットを受信した場合、受信ソケットで読み出し可能なデータになる前に、NIC もしくは、カーネル空間のネットワークスタックフィルタリングで破棄されてしまいます。
そのため、ルータやブリッジ、リピータ等のネットワーク中継機を実装する場合は、自身のアドレスとは無関係の宛先が指定されているパケットを受信できるように、プロミスキャスモード が実装されます。
主要な Socket API
socket()
int socket(int domain, int type, int protocol);
- 新しいソケットを作成する
- この関数は通信のためのエンドポイントを作成し、そのソケットの FD を返す
bind()
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- ソケットにローカルアドレスを割り当てる
- 主にサーバ側で使用される
listen()
int listen(int sockfd, int backlog);
- ソケットをリッスン状態にして接続要求を待機する
accept()
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- リッスンソケットに接続要求を待機し、クライアントとの接続を受け入れる
connect()
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- ソケットを使ってサーバに接続する
- 主にクライアント側で使用される
send() と recv()
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- データを送受信するための関数
send()
はデータを送信し、recv()
はデータを受信する
close()
int close(int sockfd);
- 通信を終了し、ソケットを閉じる
ソケットプログラミング
ソケットプログラミング(ネットワークプログラミング)では、ソケットという通信の終点を表す抽象化オブジェクト(前述の Socket API)をコールすることで、アプリケーション間のデータ通信を定義します。
具体的には、以下の段階を経て通信が実現されます。
今回は、標準的に使用される、ストリームソケットを例に挙げて紹介します。
1. ソケットの作成
まず、アプリケーションは通信を確立するためにソケットを作成します。 ソケットの生成は、socket()
関数を用います。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
AF_INET
- IPv4 インターネットプロトコルを使用することを指定
SOCK_STREAM
- ストリームソケット(TCP ソケット)を指定
0
- この引数は通常 0 が指定され、デフォルトのプロトコルを意味します
- TCP ソケットの場合、
SOCK_STREAM
とAF_INET
の組み合わせにおける標準プロトコルが自動的に選択されます
ソケットの生成に成功すると、sockfd
は新しく作成されたソケットの FD(識別番号)を受け取ります。 後続の処理はこの FD を通じて、システムコール(bind()
, listen()
, accept()
, connect()
, send()
, recv()
等)を使用し、ソケットに対するオペレーションを実行します。
2. ソケットの設定
次に、送受信するデータのプロトコルやポート、IP アドレスを設定します。 これは bind()
関数や connect()
関数で行います。
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // localhost で実行
bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
struct sockaddr_in
- IPv4 アドレスを処理するための構造体
- ソケットのアドレス情報を格納するために使用される
- 実際には
server_addr
にサーバ側の IP アドレス情報が格納される
server_addr.sin_family
- アドレスファミリとして IPv4(
AF_INET
)を指定
- アドレスファミリとして IPv4(
server_addr.sin_port
サーバがリッスンするポート番号を指定
htons
(Host TO Network Short)関数は、ネットワークバイトオーダ(ビッグエンディアン)に変換するための処理を担うポート番号は通常ホストバイトオーダ(マシン依存だが、だいたいはリトルエンディアン)で指定されるため、ネットワークバイトオーダに変換する必要がある
github.com/torvalds/linux/include/uapi/linux/byteorder
#include <stdint.h> #include <stdio.h> /* uint16_tの定義 -> 16ビットの無符号整数 */ // htons (Host to Network Short) function converts a 16-bit unsigned integer // from host byte order to network byte order (big-endian). // It swaps the byte order from little-endian to big-endian or vice versa. uint16_t htons(uint16_t host) { return (host & 0x00FF) << 8 | (host & 0xFF00) >> 8; } // テスト用の main 関数 int main() { uint16_t host_value = 0x1234; // 例:リトルエンディアンでは 0x1234, ビッグエンディアンでは 0x3412 として出力される uint16_t network_value = htons(host_value); printf("Host value: 0x%04x\n", host_value); printf("Network value: 0x%04x\n", network_value); return 0; }
host & 0x00FF
は下位 8 ビットを取り出す<< 8
は下位 8 ビットを左に 8 ビットシフト演算するhost & 0xFF00
は上位 8 ビットを取り出す>> 8
は上位 8 ビットを右に 8 ビットシフト演算する- これらを
|
演算子で結合して新しい 16 ビットの数値を生成する
server_addr.sin_addr.s_addr
- サーバの IP アドレスを格納する
sin_addr
フィールドはstruct in_addr
型であり、s_addr
はその中で具体的な IP アドレスを保持するメンバ変数inet_addr
関数で、IPv4 アドレスを文字列形式からネットワークバイトオーダーのバイナリ形式に変換
bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
- 作成したソケットを特定の IP アドレスとポートにバインドするためのシステムコール(ユーザ空間からカーネル空間へのリソースリクエスト)を実行
- システムコールによって、サーバは指定されたアドレス上の指定されたポートで接続を待機する
3. コネクションの確立
クライアントアプリケーションはサーバと接続を確立するために connect()
を呼び出し、サーバ側は listen()
と accept()
を用います。
connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
connect
- ソケットを特定のリモートアドレスとポートに接続する
- 生成した FD に対して、アドレス情報(IP アドレス + ポート番号)を含む構造体へのポインタを渡す
4. データの送受信
接続が確立された後に、send()
や recv()
もしくは read()
や write()
を使用してデータの送受信が行われます。
send(sockfd, "Hello, World!", strlen("Hello, World!"), 0);
send
- 生成した FD に対して、『Hello, World!』という文字列を書き込む
- 第 3 引数の
strlen("Hello, World!")
は送信するデータのバイト長(13 byte)を返す strlen
関数を呼び出すことで、送信するデータ長を動的に計算できる
5. ソケットクローズ
処理が完了すると、close()
を使用してソケットを閉じます。
close(sockfd);
close
- クローズする FD を渡す
ほとんどの OS のカーネル(Linux、Windows、BSD)は C 言語で記述されています。 また、Python、JavaScript、Java、Go、Rust をはじめ大半の高級言語では、高レベルな抽象化 API を提供していますが、最終的には、ネットワークライブラリも内部的にはシステムコールや C 言語で実装されたライブラリ(LIBC)に依存していることが多いです。
Socket API による echo サーバの実装
ストリームソケット(TCP)を使用して、クライアントサーバ間の通信を検証します。
今回は Linux(Debian)を使用します。
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"
NAME="Debian GNU/Linux"
VERSION_ID="11"
VERSION="11 (bullseye)"
VERSION_CODENAME=bullseye
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
サーバの実装
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 8080
int main()
{
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[1024] = {0};
const char *hello = "Hello from server"; // クライアントに返すメッセージ
// ソケットの作成(IPv4 ストリームソケット)
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
{
perror("socket failed");
exit(EXIT_FAILURE);
}
printf("[DEBUG] FD number: %d\n", server_fd);
// ソケットオプションの設定
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)))
{
perror("setsockopt");
exit(EXIT_FAILURE);
}
// アドレスとポートの割り当て
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0)
{
perror("bind failed");
exit(EXIT_FAILURE);
}
// リッスン状態にする
if (listen(server_fd, 3) < 0)
{
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listen...\n");
// クライアントからの接続を受け入れる
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0)
{
perror("accept");
exit(EXIT_FAILURE);
}
// データの受信
read(new_socket, buffer, 1024);
printf("Message from client: %s\n", buffer);
send(new_socket, hello, strlen(hello), 0);
printf("Hello message sent\n");
// ソケットのクローズ
close(new_socket);
close(server_fd);
return 0;
}
クライアントの実装
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 8080
int main()
{
int sock = 0;
struct sockaddr_in serv_addr;
const char *hello = "Hello from client"; // サーバに送信するメッセージ
char buffer[1024] = {0};
// ソケットの作成(IPv4 ストリームソケット)
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("\nSocket creation error\n");
return -1;
}
printf("[DEBUG] FD number: %d\n", sock);
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// IP アドレスの設定
if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0)
{
printf("\nInvalid address/ Address not supported\n");
return -1;
}
// サーバへの接続
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
{
printf("\nConnection Failed\n");
return -1;
}
// データの送信
send(sock, hello, strlen(hello), 0);
printf("Hello message sent\n");
read(sock, buffer, 1024);
printf("Message from server: %s\n", buffer);
// ソケットのクローズ
close(sock);
return 0;
}
- サーバ側の実行結果
[DEBUG] FD number: 3
Server listen...
Message from client: Hello from client
Hello message sent
- クライアント側の実行結果
[DEBUG] FD number: 3
Hello message sent
Message from server: Hello from server
- クライアント側の実行結果(サーバがリッスンしていない場合)
[DEBUG] FD number: 3
Connection Failed
このプログラムはクライアントからのリエクスとを一度処理すると、ソケットをクローズします。
サーバプログラムがクライアントからのリクエストを一度に一つずつ処理する方式は、一般に Iterative Server(反復サーバ)と呼ばれます。
Socket API の活用例
Socket API は様々なネットワークアプリケーションで利用されています。
- ウェブサーバ
ウェブブラウザとサーバ間で HTTP リクエストとレスポンスを送受信するために Socket API は必須です。 Apache や Nginx 等の Web サーバは、どんな言語で実装したとしても、最終的には内部で Socket API を呼び出しています。
- チャットアプリ
リアルタイムでメッセージをやり取りするチャットアプリケーションもソケットを使ってユーザ間のメッセージを送受信します。 チャットアプリでは、WebSocket 等のプロトコルが一般的に使用されています。
- ゲームネットワーク
オンラインゲームでは、プレーヤー間の状態同期やイベント通知のためにソケットが利用されます。 リアルタイム性が要求されるため、このような場面ではデータグラムソケット(UDP)を使用することが多いです。
まとめ
本記事では、ネットワーク通信の裏側で利用されている Socket API について紹介しました。 Socket API は、ネットワークプログラミングの基礎を形成する非常にコアなインターフェースです。
近年では、高級言語が普及したことで、普段の開発の中でソケットを意識することはほとんどなくなりましたが、どんな Web サービスも Socket API 無しでは通信の仕組みを説明できません。 ネットワークソケットの実装は、C 言語の知識や TCP/IP、コンピュータネットワークへの理解も求められるため非常にややこしい分野ではありますが、Web サービスの開発者は理解しておくことが重要です。
一度、C 言語で API を実装して理解を深めておけば、高級言語を使った HTTP サーバの構築はそれほど難しくないと思います。