概要
Apache2.4 には AcceptFilter ディレクティブというものがあり、これを使用すると listen ソケットに TCP_DEFER_ACCEPT
が設定されます。
以前、カーネル2.4を使っていたころに、このオプションの動作に悩まされて調査したことがあったのですが、最新のカーネルではまた動作が変わっていることを知り、動作検証してみたので結果を残しておこうと思い立ちました。
Apache2.4 の AcceptFilter ディレクティブの説明には以下の記載があります。
Listen しているソケットに対して、OS が固有に持っているプロトコルについての最適化を 有効にするディレクティブです。大前提となる条件は、データが受信されるか HTTP リクエスト全体がバッファされるかするまで、カーネルがサーバプロセスに ソケットを送らないようになっている、ということです。現在サポートされているのは、 FreeBSD の Accept Filter と Linux のプリミティブな TCP_DEFER_ACCEPT のみです。
Linux でのデフォルト値は :
AcceptFilter http data
AcceptFilter https data
Linux の TCP_DEFER_ACCEPT は HTTP リクエストのバッファリングを サポートしていません。none 以外の値で TCP_DEFER_ACCEPT が有効になります。詳細については Linux man ページ tcp(7) を参照してください。
TCP_DEFER_ACCEPT
はソケットオプションですが、Linux のマニュアル tcp(7) には以下のように記載されています。
TCP_DEFER_ACCEPT (since Linux 2.4)
Allow a listener to be awakened only when data arrives on
the socket. Takes an integer value (seconds), this can
bound the maximum number of attempts TCP will make to
complete the connection. This option should not be used
in code intended to be portable.
TCP_DEFER_ACCEPT (Linux 2.4 以降)
これを用いると、リスナはデータがソケットに到着した時のみ目覚めるようになる。
整数値 (秒) をとり、 TCP が接続を完了しようと試みる回数を制限できる。 移植性の必要なプログラムではこのオプションを用いるべきではない。
マニュアルの記載も不正確で、この記載だけで動作を理解するのは難しいと思います。
動作環境
- Apache/2.4.57 (AlmaLinux)
- カーネル: 5.14.0-362.8.1.el9_3.x86_64
TCP_DEFER_ACCEPTオプションとは
TCP_DEFER_ACCEPT
はソケットオプションで、listen(2) ソケットに対して以下のように setsockopt(2) で設定します。
int sec = 5;
setsockopt(s, IPPROTO_TCP, TCP_DEFER_ACCEPT, (void *)&sec, sizeof(int));
通常、TCPのハンドシェイクが完了すれば、サーバ側の TCP のステータスは SYN_RECEIVED から ESTABLISHED に遷移して、accept(2) が動作しますが、TCP_DEFER_ACCEPT
を設定した場合、データが送られてくるまで SYN_RECEIVED 状態のまま accept(2) を遅延するという動きになります。
おそらく、コネクションを貼った後になかなかデータを送信してこないクライアントが大量にあるときに、待機するスレッドやプロセスの生成を抑止するためと思います。
カーネル2.4では以下のようにデータが送られてくるまで SYN/ACK の再送が繰り返されるという仕様でした。
connect(2) した後にすぐにデータを送信しないと以下のようなパケットフローになります。
このパケットトレースを見たらネットワーク障害と思ってしまうのですが、これが TCP_DEFER_ACCEPT
の仕様でした。
この再送の仕様は現在のカーネル(5.14)では見直されて動作が変わっています。
Apache の AcceptFilter の挙動
実際に Apache2.4(カーネル5.14) のサーバに connect(2) した後、120秒待機してからデータを送信してみると、パケットシーケンスは以下のようになります。
TCP のハンドシェイクが完了した後、30秒後にサーバから SYN/ACK が再送されています。
その後、20秒後にサーバから FIN で切断。
120秒後にリクエストを送信していますが、すでに切断されているので RST が返されています。
何が起きているのでしょうか。
30秒後の SYN/ACK
30秒後に SYN/ACK が再送されているのが、TCP_DEFER_ACCEPT
の動作でした。サーバ側はこのタイミングでデータの受信待ちをあきらめて accept(2) が動作しています。
30秒という数値は setsockopt(2) のパラメータで指定する数値で Apacheでは 30 固定でハードコーディングされていました。
static void ap_apply_accept_filter(apr_pool_t *p, ap_listen_rec *lis,
server_rec *server)
{
apr_socket_t *s = lis->sd;
const char *accf;
apr_status_t rv;
const char *proto;
proto = lis->protocol;
if (!proto) {
proto = ap_get_server_protocol(server);
}
accf = find_accf_name(server, proto);
if (accf) {
#if APR_HAS_SO_ACCEPTFILTER
/* In APR 1.x, the 2nd and 3rd parameters are char * instead of
* const char *, so make a copy of those args here.
*/
rv = apr_socket_accept_filter(s, apr_pstrdup(p, accf),
apr_pstrdup(p, ""));
if (rv != APR_SUCCESS && !APR_STATUS_IS_ENOTIMPL(rv)) {
ap_log_perror(APLOG_MARK, APLOG_WARNING, rv, p, APLOGNO(00075)
"Failed to enable the '%s' Accept Filter",
accf);
}
#else
rv = apr_socket_opt_set(s, APR_TCP_DEFER_ACCEPT, 30);
if (rv != APR_SUCCESS && !APR_STATUS_IS_ENOTIMPL(rv)) {
ap_log_perror(APLOG_MARK, APLOG_WARNING, rv, p, APLOGNO(00076)
"Failed to enable APR_TCP_DEFER_ACCEPT");
}
#endif
}
}
APR_HAS_SO_ACCEPTFILTER
は FreeBSD用のオプションで Linux の場合は未指定。
APRライブラリ内の apr_socket_opt_set 関数が呼ばれています。
apr_socket_opt_set の実装は以下のようになっており、30 がそのまま setsockopt(2) のパラメータとして渡されています。
case APR_TCP_DEFER_ACCEPT:
#if defined(TCP_DEFER_ACCEPT)
if (apr_is_option_set(sock, APR_TCP_DEFER_ACCEPT) != on) {
int optlevel = IPPROTO_TCP;
int optname = TCP_DEFER_ACCEPT;
if (setsockopt(sock->socketdes, optlevel, optname,
(void *)&on, sizeof(int)) == -1) {
return errno;
}
apr_set_option(sock, APR_TCP_DEFER_ACCEPT, on);
}
#else
return APR_ENOTIMPL;
#endif
break;
TCP_DEFER_ACCEPT
のマニュアルには「 整数値 (秒) をとり、 TCP が接続を完了しようと試みる回数を制限できる」と書いてありますが、実際には指定した秒数だけ待機した後 SYN/ACK を再送して accept(2) するという動作になるので「回数を制限できる」という表現は正確ではないように思います。
20秒後の FIN
その後 20秒で切断されているのは、Apacheの KeepAlive が20秒で設定されているためでした。
クライアント側は SYN/ACK の受信で ESTABLISHED に遷移していますが、データを送信するまではサーバ側は ESTABLISHED に遷移しません。
30秒間データを送信せずに待機していると、サーバ側が ESTABLISHED に遷移し、ここから KeepAlive を開始するという動きになります。
まとめ
ブラウザ相手のWebサーバならおそらく気にすることのない動作なのですが、コネクションプールを使った REST API 用途などで Apache を使用する場合には、KeepAlive のタイムアウトまわりをチューニングする場合もあり、この動作を知っておいたほうがいいかもしれません。