Apache AcceptFilter の裏側

## 概要

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) で設定します。

```c
        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) した後にすぐにデータを送信しないと以下のようなパケットフローになります。

![kernel2.4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476949/660bb7e2-d002-8299-ed4b-eb1c87b479ec.png)

このパケットトレースを見たらネットワーク障害と思ってしまうのですが、これが `TCP_DEFER_ACCEPT` の仕様でした。
この再送の仕様は現在のカーネル(5.14)では見直されて動作が変わっています。

## Apache の AcceptFilter の挙動

実際に Apache2.4(カーネル5.14) のサーバに connect(2) した後、120秒待機してからデータを送信してみると、パケットシーケンスは以下のようになります。

![filter_on.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476949/ec4162de-5d03-27dc-fb1f-9d6a21c820cd.png)

TCP のハンドシェイクが完了した後、30秒後にサーバから SYN/ACK が再送されています。
その後、20秒後にサーバから FIN で切断。
120秒後にリクエストを送信していますが、すでに切断されているので RST が返されています。

何が起きているのでしょうか。

### 30秒後の SYN/ACK

30秒後に SYN/ACK が再送されているのが、`TCP_DEFER_ACCEPT`の動作でした。サーバ側はこのタイミングでデータの受信待ちをあきらめて accept(2) が動作しています。
30秒という数値は setsockopt(2) のパラメータで指定する数値で Apacheでは 30 固定でハードコーディングされていました。

```c
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) のパラメータとして渡されています。

```c
    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 のタイムアウトまわりをチューニングする場合もあり、この動作を知っておいたほうがいいかもしれません。