IPVS のセッション同期パケットを作って送ろう

# IPVS について

[LVS Project](http://www.linuxvirtualserver.org/) には `IPVS` 以外にも `KTCPVS` などのソフトウェアがありますが、現在では `LVS (Linux Virtual Server)` と言うとほぼ `IPVS` のことを指しているように思います。

`IPVS` は Linux でロードバランサを構築するためソフトウェアで、Linux kernel 内部に実装されています。
基本的に **L4 まで** の情報を見てパケットをリアルサーバに転送するものです。
(FTP や SIP など、一部アプリケーションは上位レイヤの処理もできます)

`ipvsadm` コマンドで設定を変更することもできますが、本番環境で利用する場合はヘルスチェックや HA などを求めて `keepalived` から `IPVS` を使うことが多いと思います。

ここでは `IPVS` におけるセッション同期の話題を主とします。
`LVS (IPVS)` 自体の詳細は以下など、他の文書を参照してください。

- [Linux Virtual Server - Wikipedia](https://en.wikipedia.org/wiki/Linux_Virtual_Server)
- [LVS-HOWTO](http://www.austintek.com/LVS/LVS-HOWTO/HOWTO/)

今回の実証環境は `CentOS 7` です。`ip_vs` はカーネルモジュールとして用意されていて、すぐに利用が可能です。



# IPVS セッションの同期

ロードバランサとして機能するためには、自分の背後にいる複数のリアルサーバに対して、適切にパケットを転送する必要があります。
1つの TCP コネクションにおいて、あるパケットが リアルサーバ A に転送されたのに続きのパケットが リアルサーバ B に届くようなことになっては、通信は成立しません。

そのため、`IPVS` では セッション情報をハッシュテーブルとして保持しています。(以下、セッションテーブルと呼びます)
パケットを受信したらセッションテーブルを検索し、既存のセッションにマッチしたらそのセッション情報に従ってリアルサーバに転送します。

```shell-session:セッションテーブルの例
# ipvsadm -Lnc
IPVS connection entries
pro expire state       source             virtual            destination
TCP 01:52  FIN_WAIT    192.168.37.248:48024 192.168.37.80:80   192.168.37.111:80
TCP 14:49  ESTABLISHED 192.168.37.248:48022 192.168.37.80:80   192.168.37.112:80
TCP 01:53  FIN_WAIT    192.168.37.248:48026 192.168.37.80:80   192.168.37.112:80
```

`keepalived` でロードバランサを冗長化したいと考えた場合、`VRRP` を使って VIP を作ることになりますが、単に `VRRP` の機能だけを使ってフェイルオーバすると少々困ったことになります。

`IPVS` の設定はそれぞれの `keepalived` から適切に投入されているとしても、セッション情報は Master だったロードバランサしか持っておらず、Backup から Master に昇格したロードバランサは、既存のセッションについて何も知りません。

新規コネクションは適切にさばけますが、確立済みのコネクションについては転送すべきリアルサーバが分かりません。
私が確認した限り、TCP であればこのような場合はロードバランサが `TCP-RST` を応答してコネクションを切断するようです。(無視してドロップしないところに優しさを感じます)
UDP は確認していませんが、他に判断のしようがないので新規セッション扱いになると思われます。

切断されてもクライアントがリトライすればいいでしょ？という判断もあると思いますが、それでは困る環境もありますし、救えるものは救ってあげたいものです。
この問題は `sync daemon` を利用することで解決できます。


## IPVS connection sync daemon

`sync daemon` は Master と Backup に分かれていて、起動時に Master にするか、Backup にするか、ID (Sync ID) を何にするかなどを指示します。
`keepalived` においては `lvs_sync_daemon` という設定項目になっていて、Master/Backup は VRRP のステータスに基づいて自動的に判別してくれます。ID はデフォルトで `VRRP` の `VRID` と同じ値を使用します。

```shell-session:Master_sync_daemon
# ps auxww | grep 'ipv[s]'
root      13680  0.0  0.0      0     0 ?        S    00:00   0:00 [ipvs-m:0:0]
```

```shell-session:Backup_sync_daemon
# ps auxww | grep 'ipv[s]'
root      14011  0.0  0.0      0     0 ?        S    00:00   0:00 [ipvs-b:0:0]
```

`sync daemon` の Master/Backup は `VRRP` の役割と同様で、VRRP Master として通常時にパケットを処理するロードバランサは Master の daemon を動かし、VRRP Backup になっているロードバランサは Backup の daemon を動かします。
Master はパケットを受信すると、一定の頻度でそのパケットによって更新されたセッション情報を UDP のパケットとして生成してマルチキャストします。
Master と Backup で ID が一致していれば、Backup 側はそのパケットを利用して自身のセッションテーブルを更新します。

詳しくは以下など、他の文書を参照してください。

- [LVS-HOWTO: 38. LVS: Server State Sync Demon, syncd (saving the director's connection state on failover)](http://www.austintek.com/LVS/LVS-HOWTO/HOWTO/LVS-HOWTO.server_state_sync_demon.html)
- [IPVS Connection Synchronization](http://www.linuxvirtualserver.org/docs/sync.html)

`sync daemon` によってセッション情報が同期されていれば、`VRRP` でフェイルオーバが発生した後も、ロードバランサはセッションテーブルから適切なセッションを検索し、これまでと同じリアルサーバにパケットを転送できるようになります。

`keepalived` を使っていれば、 `VRRP` で Backup から Master に昇格した際に、`Backup sync daemon` の停止と `Master sync daemon` の起動も含めて実行してくれます。
このため、元 Master のロードバランサを復旧させて Backup として組み込めば、セッション同期が再開されます。

## 課題：更新されないセッションの取り扱い

セッション同期が再開されるため、昇格したロードバランサが落ちても安心、と言いたいところですが、実はまだ課題が残っています。

セッション同期のトリガはパケット受信です。
また、`sync_threshold` や `sync_refresh_period` の設定値も影響しており、毎回セッション同期するわけではありません。

```
ip_vs_in()
  ip_vs_sync_conn()
    sb_queue_tail() <= sync message を queue に入れる。
                       Master daemon thread はこの queue から取り出してパケットを送る
```

そのため、長時間通信が発生しないセッションが同期されないままとなってしまいます。
その間に `keepalived` の `preempt` 設定によってフェイルバックさせたり、昇格したロードバランサがダウンして再びフェイルオーバが発生してしまうと同期されないまま失われるセッションが生じることになります。
`TCP keep-alive` が有効な場合はアプリケーションが通信を要求しない状態でもパケットを送ってくれますが、通常は頻繁に送られるものではありません。

この課題は `nopreempt` を設定してフェイルバックさせないことでおおよそ回避できます。
同期前に再びフェイルオーバが発生する可能性もありますが、その確率は高くないのでそのリスクは受容してもよいでしょう。


# 本題：自分でセッション同期する

前置きが長くなりましたが、ここからが本題です。
`IPVS` のセッション同期パケットを自分で作って投げれば、長時間通信がないセッションでも任意のタイミングでセッションを同期させられるのでは？と思いつき、試してみました。

そのためには、以下のような情報が必要です。

- セッション同期パケットにはどんなデータが乗っているのか？
- そのデータはユーザ空間から収集可能か？

`ip_vs` モジュールに手を加えることも考えられますが、ここではユーザ空間のプログラムができる範囲でものを考えます。

まずはセッション同期パケットの構造を調べていきます。


## IPVS sync message パケットの構造

セッション同期パケットはデフォルトで `224.0.0.81:8848` 宛てに送信されます。
手始めにパケットダンプを Wireshark に食わせると、不思議な情報が表示されます。

![sync_message_from_master_daemon.png](https://qiita-image-store.s3.amazonaws.com/0/44206/3e2b5212-61ba-52ff-e746-d9ac0d54aabb.png)

このパケットは `ipvsadm --start-daemon master --syncid 15` で起動した `sync daemon` から送られたものです。
Connection Count が 0 になっていて、これは送ったセッション情報の数を示すと予想されるので、違和感があります。
Sync ID は daemon の起動時に指定したものが見えますが、解析されていないデータが多くあります。

Wireshark だけではわかりそうにないので、ここからは Linux のソースコードを参照しながらいきます。
バージョンは `4.14` です。

### IPVS sync message ヘッダフォーマット

sync message のヘッダ構造を示したコメントがあるので、抜粋します。

```c:net/netfilter/ipvs/ip_vs_sync.c
/*
  The master mulitcasts messages (Datagrams) to the backup load balancers
  in the following format.

 Version 1:
  Note, first byte should be Zero, so ver 0 receivers will drop the packet.

       0                   1                   2                   3
       0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |      0        |    SyncID     |            Size               |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |  Count Conns  |    Version    |    Reserved, set to Zero      |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                                                               |
      |                    IPVS Sync Connection (1)                   |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                            .                                  |
      ~                            .                                  ~
      |                            .                                  |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                                                               |
      |                    IPVS Sync Connection (n)                   |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

 Version 0 Header
       0                   1                   2                   3
       0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |  Count Conns  |    SyncID     |            Size               |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                    IPVS Sync Connection (1)                   |
*/
```

メッセージフォーマットは `Version 0` と `Version 1` があり、`Version 1` のパケットは `Version 0` として処理するサーバにとっては `Count Conns = 0` であるように見えるため、受信してもドロップするようです。
さきほど Wireshark で見た `Connection Count: 0` は `Version 0` の dissector しか実装されていないためだったようです。

CentOS 7 であればデフォルトは `Version 1` で、 `net.ipv4.vs.sync_version` のパラメータで `Version 0` に変更することもできますが、ここでは `Version 1` にのみ注目して進みます。
このヘッダを組み立てるための情報を知ることができるでしょうか。

`SyncID` は自分で設定したパラメータで、`ipvsadm` コマンドからも確認できるので、問題ありません。
`Size` と `Count Conns` は内容が確定すれば決まるでしょう。
`Version` はこのメッセージフォーマットのバージョンです。今回は 1 になります。
`Reserved` は書かれている通り、0 にします。

ヘッダは埋められそうです。

### IPVS sync connection フォーマット

続いて IPVS Sync Connection を確認します。

```c:net/netfilter/ipvs/ip_vs_sync.c
/*
     Sync Connection format (sync_conn)

       0                   1                   2                   3
       0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |    Type       |    Protocol   | Ver.  |        Size           |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                             Flags                             |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |            State              |         cport                 |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |            vport              |         dport                 |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                             fwmark                            |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                             timeout  (in sec.)                |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                              ...                              |
      |                        IP-Addresses  (v4 or v6)               |
      |                              ...                              |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  Optional Parameters.
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      | Param. Type    | Param. Length |   Param. data                |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+                               |
      |                              ...                              |
      |                               +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                               | Param Type    | Param. Length |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
      |                           Param  data                         |
      |         Last Param data should be padded for 32 bit alignment |
      +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
*/

/*
 *  Type 0, IPv4 sync connection format
 */
struct ip_vs_sync_v4 {
        __u8                    type;
        __u8                    protocol;       /* Which protocol (TCP/UDP) */
        __be16                  ver_size;       /* Version msb 4 bits */
        /* Flags and state transition */
        __be32                  flags;          /* status flags */
        __be16                  state;          /* state info   */
        /* Protocol, addresses and port numbers */
        __be16                  cport;
        __be16                  vport;
        __be16                  dport;
        __be32                  fwmark;         /* Firewall mark from skb */
        __be32                  timeout;        /* cp timeout */
        __be32                  caddr;          /* client address */
        __be32                  vaddr;          /* virtual address */
        __be32                  daddr;          /* destination address */
        /* The sequence options start here */
        /* PE data padded to 32bit alignment after seq. options */
};
```

`Type` は同じファイルのコメントを見ると Type 0 と Type 2 があるようです。
今回は IPv4 だけに着目します。IPv4 のメッセージは Type 0 です。
`Protocol` は構造体のコメントの通り、TCP `(IPPROTO_TCP)` か UDP `(IPPROTO_UDP)` になります。 `ipvsadm -Lnc` で表示されます。
`Version` はここからはどうなるか読み取れません。別途調査する必要があります。
`Size` は 1つの Sync Connection のバイト数になると思われるので、自分で決められるでしょう。 しかし `Optional Parameter` があると計算が面倒なので、Option はなるべく無視する方向で進めます。

`Flags` はよく分かりません。`ipvsadm -Ln` だと scheduler flag という項目がありますが、`ipvsadm -Lnc` では `Flags` らしいものは表示されません。どういうものなのか、調査が必要です。
`State` は `ipvsadm -Lnc` コマンドで表示されますが、ESTABLISHED のように文字列表記です。これは適切に数値にマッピングできるか確認する必要があります。

`cport, vport, dport` はそれぞれクライアントのポート番号、Virtual Service のポート番号、リアルサーバのポート番号と予想されます。`ipvsadm -Lnc` で表示されます。
`fwmark` は IPVS の Virtual Service で設定している firewall mark でしょう。これは自分で設定しているので分かりそうですが、`ipvsadm -Ln` で Virtual Service に設定した fwmark の値は表示されるものの、 `ipvsadm -Lnc` で表示されるセッション情報には fwmark がありません。セッションから fwmark を判別できるか調査が必要です。
`timeout` はセッション情報が削除されるまでの秒数で、`ipvsadm -Lnc` コマンドで expire と表示されている値と思われます。
`IP-addresses` は構造体を見るに `caddr, vaddr, daddr` でそれぞれクライアントの IP アドレス、Virtual Service の IP アドレス、リアルサーバの IP アドレスのようです。`ipvsadm -Lnc` で表示されます。

`Optinal Parameter` はどんなものがあるのか、また使う必要があるかを調べます。

## パケットを作るための情報収集

さて、1つずつ調べていきます。


### IPVS Sync Connection の Version

メッセージ全体のバージョン以外にも、Sync Connection のバージョンがあるようです。
`ip_vs_sync_conn()` 内でこれをセットしている箇所があります。

```c:net/netfilter/ipvs/ip_vs_sync.c
        /* Set message type  & copy members */
        s->v4.type = (cp->af == AF_INET6 ? STYPE_F_INET6 : 0);
        s->v4.ver_size = htons(len & SVER_MASK);        /* Version 0 */
        s->v4.flags = htonl(cp->flags & ~IP_VS_CONN_F_HASHED);
        s->v4.state = htons(cp->state);
        s->v4.protocol = cp->protocol;
        s->v4.cport = cp->cport;
        s->v4.vport = cp->vport;
        s->v4.dport = cp->dport;
        s->v4.fwmark = htonl(cp->fwmark);
        s->v4.timeout = htonl(cp->timeout / HZ);
        m->nr_conns++;
```

`s->v4` は `struct ip_vs_sync_v4` です。ver_size のコメントを見るに、 `Version 0` という扱いのようです。
`SVER_MASK` は `0x0fff` と定義されているので、とりあえず同じようにして 0 をセットすれば良さそうです。



### Flags, fwmark

`Flags` は `ipvsadm -Lnc` には表示されない項目ですが、何が入っているのでしょうか。

```
ip_vs_in()
  tcp_conn_schedule()
    ip_vs_service_find()
    ip_vs_schedule()
      ip_vs_conn_new() <= 新しいセッションが生まれる
        ip_vs_bind_dest() <= Flags をセッションにセット
        ip_vs_conntrack_enabled()
```

新規セッション作成時に生まれ、宛先として選択された `リアルサーバの設定` に従ってセットされるようです。
また、`ip_vs_conn_new()` の中で `net.ipv4.vs.conntrack` が有効な場合に `IP_VS_CONN_F_NFCT` がセットされるようです。
その後更新されることもあるようですが、残念ながら直接 `struct ip_vs_conn` の情報を直接取り出す方法は見つかりませんでした。

代わりに `リアルサーバの設定` なら取り出せそうです。
`Flags` の更新箇所を網羅できていないので不充分かもしれませんが、`リアルサーバの設定` に含まれる `Flags` を読み取って、セッション情報からどの `リアルサーバの設定` に紐づくかをたどれば正しい情報に近い `Flags` になりそうです。

また、幸いにして Backup sync daemon は sync message を受けとった際に、自分でセッションテーブルや設定を探して `Flags` を埋めてくれるようです。
パケットを作って投げる前に Backup 側にも設定が入っていることを確認しておけば、最悪 `Flags` は 0 でも何とかなりそうです。

```
sync_thread_backup()
  ip_vs_process_message()
    ip_vs_proc_sync_conn()
      ip_vs_proc_conn()
        ip_vs_find_dest()
        ip_vs_conn_new()
```

今回は `リアルサーバの設定` から `Flags` を埋めていこうと思いますが、1つ問題があります。
`ipvsadm -Lnc` で得られる情報を見ても、これが TCP や UDP の Virtual Service から作られたセッションなのか、それとも FWM の Virtual Service から作られたセッションなのかは判別できません。

ややこしいので、今回は FWM の Virutal Service については考慮しないことにします。

`IP_VS_CONN_F_NFCT` のセットも `ip_vs_conn_new()` と同じように実施します。

### State

`ipvsadm -Lnc` で文字列が表示されているのは分かっていますが、どうやって情報を取ってくるのでしょうか。
`ipvsadm` コマンドのソースコードを確認します。確認したバージョンは `1.29` です。

```c:ipvsadm.c
void list_conn(unsigned int format)
{
        static char buffer[256];
        FILE *handle;

        handle = fopen(CONN_PROC_FILE, "r");
        if (!handle) {
                fprintf(stderr, "cannot open file %s\n", CONN_PROC_FILE);
                exit(1);
        }

        /* read the first line */
        if (fgets(buffer, sizeof(buffer), handle) == NULL) {
                fprintf(stderr, "unexpected input from %s\n",
                        CONN_PROC_FILE);
                exit(1);
        }
        printf("IPVS connection entries\n");
        if (format & FMT_PERSISTENTCONN)
                printf("pro expire %-11s %-18s %-18s %-18s %-16s %s\n",
                       "state", "source", "virtual", "destination",
                       "pe name", "pe_data");
        else
                printf("pro expire %-11s %-18s %-18s %s\n",
                       "state", "source", "virtual", "destination");

        /*
         * Print the VS information according to the format
         */
        while (!feof(handle)) {
                if (fgets(buffer, sizeof(buffer), handle))
                        print_conn(buffer, format);
        }

        fclose(handle);
}
```

`CONN_PROC_FILE` を読んでいるだけです。

```c:ipvsadm.c
#define CONN_PROC_FILE          "/proc/net/ip_vs_conn"
```

また Linux カーネルに戻って、`/proc/net/ip_vs_conn` がどのようにして表示されるのか調べます。

```c:net/net/filter/ipvs/ip_vs_conn.c
/*
 * per netns init and exit
 */
int __net_init ip_vs_conn_net_init(struct netns_ipvs *ipvs)
{
        atomic_set(&ipvs->conn_count, 0);

        proc_create("ip_vs_conn", 0, ipvs->net->proc_net, &ip_vs_conn_fops);
        proc_create("ip_vs_conn_sync", 0, ipvs->net->proc_net,
                    &ip_vs_conn_sync_fops);
        return 0;
}
```

```c:net/net/filter/ipvs/ip_vs_conn.c
static const struct file_operations ip_vs_conn_fops = {
        .owner   = THIS_MODULE,
        .open    = ip_vs_conn_open,
        .read    = seq_read,
        .llseek  = seq_lseek,
        .release = seq_release_net,
};
```

```c:net/net/filter/ipvs/ip_vs_conn.c
static int ip_vs_conn_open(struct inode *inode, struct file *file)
{
        return seq_open_net(inode, file, &ip_vs_conn_seq_ops,
                            sizeof(struct ip_vs_iter_state));
}
```

```c:net/net/filter/ipvs/ip_vs_conn.c
static const struct seq_operations ip_vs_conn_seq_ops = {
        .start = ip_vs_conn_seq_start,
        .next  = ip_vs_conn_seq_next,
        .stop  = ip_vs_conn_seq_stop,
        .show  = ip_vs_conn_seq_show,
};
```

```c:net/net/filter/ipvs/ip_vs_conn.c
static int ip_vs_conn_seq_show(struct seq_file *seq, void *v)
{

        if (v == SEQ_START_TOKEN)
                seq_puts(seq,
   "Pro FromIP   FPrt ToIP     TPrt DestIP   DPrt State       Expires PEName PEData\n");
        else {
                const struct ip_vs_conn *cp = v;
                struct net *net = seq_file_net(seq);
                char pe_data[IP_VS_PENAME_MAXLEN + IP_VS_PEDATA_MAXLEN + 3];
                size_t len = 0;
                char dbuf[IP_VS_ADDRSTRLEN];

                if (!net_eq(cp->ipvs->net, net))
                        return 0;
                if (cp->pe_data) {
                        pe_data[0] = ' ';
                        len = strlen(cp->pe->name);
                        memcpy(pe_data + 1, cp->pe->name, len);
                        pe_data[len + 1] = ' ';
                        len += 2;
                        len += cp->pe->show_pe_data(cp, pe_data + len);
                }
                pe_data[len] = '\0';

#ifdef CONFIG_IP_VS_IPV6
                if (cp->daf == AF_INET6)
                        snprintf(dbuf, sizeof(dbuf), "%pI6", &cp->daddr.in6);
                else
#endif
                        snprintf(dbuf, sizeof(dbuf), "%08X",
                                 ntohl(cp->daddr.ip));

#ifdef CONFIG_IP_VS_IPV6
                if (cp->af == AF_INET6)
                        seq_printf(seq, "%-3s %pI6 %04X %pI6 %04X "
                                "%s %04X %-11s %7lu%s\n",
                                ip_vs_proto_name(cp->protocol),
                                &cp->caddr.in6, ntohs(cp->cport),
                                &cp->vaddr.in6, ntohs(cp->vport),
                                dbuf, ntohs(cp->dport),
                                ip_vs_state_name(cp->protocol, cp->state),
                                (cp->timer.expires-jiffies)/HZ, pe_data);
                else
#endif
                        seq_printf(seq,
                                "%-3s %08X %04X %08X %04X"
                                " %s %04X %-11s %7lu%s\n",
                                ip_vs_proto_name(cp->protocol),
                                ntohl(cp->caddr.ip), ntohs(cp->cport),
                                ntohl(cp->vaddr.ip), ntohs(cp->vport),
                                dbuf, ntohs(cp->dport),
                                ip_vs_state_name(cp->protocol, cp->state),
                                (cp->timer.expires-jiffies)/HZ, pe_data);
        }
        return 0;
}
```

`ESTABLISHED` の文字列は `ip_vs_state_name()` から返されるようです。

```c:net/netfilter/ipvs/ip_vs_proto.c
const char * ip_vs_state_name(__u16 proto, int state)
{
        struct ip_vs_protocol *pp = ip_vs_proto_get(proto);

        if (pp == NULL || pp->state_name == NULL)
                return (IPPROTO_IP == proto) ? "NONE" : "ERR!";
        return pp->state_name(state);
}
```

TCP での `state_name` の実装を見ます。

```c:net/netfilter/ipvs/ip_vs_proto_tcp.c
struct ip_vs_protocol ip_vs_protocol_tcp = {
        .name =                 "TCP",
        .protocol =             IPPROTO_TCP,
        .num_states =           IP_VS_TCP_S_LAST,
        .dont_defrag =          0,
        .init =                 NULL,
        .exit =                 NULL,
        .init_netns =           __ip_vs_tcp_init,
        .exit_netns =           __ip_vs_tcp_exit,
        .register_app =         tcp_register_app,
        .unregister_app =       tcp_unregister_app,
        .conn_schedule =        tcp_conn_schedule,
        .conn_in_get =          ip_vs_conn_in_get_proto,
        .conn_out_get =         ip_vs_conn_out_get_proto,
        .snat_handler =         tcp_snat_handler,
        .dnat_handler =         tcp_dnat_handler,
        .csum_check =           tcp_csum_check,
        .state_name =           tcp_state_name,
        .state_transition =     tcp_state_transition,
        .app_conn_bind =        tcp_app_conn_bind,
        .debug_packet =         ip_vs_tcpudp_debug_packet,
        .timeout_change =       tcp_timeout_change,
};
```


```c:net/netfilter/ipvs/ip_vs_proto_tcp.c
static const char * tcp_state_name(int state)
{
        if (state >= IP_VS_TCP_S_LAST)
                return "ERR!";
        return tcp_state_name_table[state] ? tcp_state_name_table[state] : "?";
}
```


```c:net/netfilter/ipvs/ip_vs_proto_tcp.c
static const char *const tcp_state_name_table[IP_VS_TCP_S_LAST+1] = {
        [IP_VS_TCP_S_NONE]              =       "NONE",
        [IP_VS_TCP_S_ESTABLISHED]       =       "ESTABLISHED",
        [IP_VS_TCP_S_SYN_SENT]          =       "SYN_SENT",
        [IP_VS_TCP_S_SYN_RECV]          =       "SYN_RECV",
        [IP_VS_TCP_S_FIN_WAIT]          =       "FIN_WAIT",
        [IP_VS_TCP_S_TIME_WAIT]         =       "TIME_WAIT",
        [IP_VS_TCP_S_CLOSE]             =       "CLOSE",
        [IP_VS_TCP_S_CLOSE_WAIT]        =       "CLOSE_WAIT",
        [IP_VS_TCP_S_LAST_ACK]          =       "LAST_ACK",
        [IP_VS_TCP_S_LISTEN]            =       "LISTEN",
        [IP_VS_TCP_S_SYNACK]            =       "SYNACK",
        [IP_VS_TCP_S_LAST]              =       "BUG!",
};
```

```c:include/net/ip_vs.h
/* TCP State Values */
enum {
        IP_VS_TCP_S_NONE = 0,
        IP_VS_TCP_S_ESTABLISHED,
        IP_VS_TCP_S_SYN_SENT,
        IP_VS_TCP_S_SYN_RECV,
        IP_VS_TCP_S_FIN_WAIT,
        IP_VS_TCP_S_TIME_WAIT,
        IP_VS_TCP_S_CLOSE,
        IP_VS_TCP_S_CLOSE_WAIT,
        IP_VS_TCP_S_LAST_ACK,
        IP_VS_TCP_S_LISTEN,
        IP_VS_TCP_S_SYNACK,
        IP_VS_TCP_S_LAST
};
```

`State` の文字列と数値は 1:1 でマッピングできることが分かります。
`/proc/net/ip_vs_conn` を読んで対応する数値を `State` に入れます。



### Optional Parameters

残るは `Option` です。処理が面倒なので可能なら無視したいですが、無視できるか調べます。

Backup daemon が受信したメッセージの `Option` で処理を分岐しているのは `ip_vs_proc_sync_conn()` の以下の部分のようです。

```c:net/netfilter/ipvs/ip_vs_sync.c
        /* Process optional params check Type & Len. */
        while (p < msg_end) {
                int ptype;
                int plen;

                if (p+2 > msg_end)
                        return -30;
                ptype = *(p++);
                plen  = *(p++);

                if (!plen || ((p + plen) > msg_end))
                        return -40;
                /* Handle seq option  p = param data */
                switch (ptype & ~IPVS_OPT_F_PARAM) {
                case IPVS_OPT_SEQ_DATA:
                        if (ip_vs_proc_seqopt(p, plen, &opt_flags, &opt))
                                return -50;
                        break;

                case IPVS_OPT_PE_DATA:
                        if (ip_vs_proc_str(p, plen, &pe_data_len, &pe_data,
                                           IP_VS_PEDATA_MAXLEN, &opt_flags,
                                           IPVS_OPT_F_PE_DATA))
                                return -60;
                        break;

                case IPVS_OPT_PE_NAME:
                        if (ip_vs_proc_str(p, plen,&pe_name_len, &pe_name,
                                           IP_VS_PENAME_MAXLEN, &opt_flags,
                                           IPVS_OPT_F_PE_NAME))
                                return -70;
                        break;

                default:
                        /* Param data mandatory ? */
                        if (!(ptype & IPVS_OPT_F_PARAM)) {
                                IP_VS_DBG(3, "BACKUP, Unknown mandatory param %d found\n",
                                          ptype & ~IPVS_OPT_F_PARAM);
                                retc = 20;
                                goto out;
                        }
                }
                p += plen;  /* Next option */
        }
```

`struct ip_vs_sync_v4` のコメントを見る限り、`Option` には `sequence` と `PE` がありそうです。
他にありそうか、define しているところを grep してみます。

```c
#define IPVS_OPT_SEQ_DATA       1
#define IPVS_OPT_PE_DATA        2
#define IPVS_OPT_PE_NAME        3
#define IPVS_OPT_PARAM          7
#define IPVS_OPT_F_SEQ_DATA     (1 << (IPVS_OPT_SEQ_DATA-1))
#define IPVS_OPT_F_PE_DATA      (1 << (IPVS_OPT_PE_DATA-1))
#define IPVS_OPT_F_PE_NAME      (1 << (IPVS_OPT_PE_NAME-1))
#define IPVS_OPT_F_PARAM        (1 << (IPVS_OPT_PARAM-1))
```

`PARAM` は判定にしか使われておらず、`SEQ` と `PE_NAME, PE_DATA` を見ればよさそうです。

#### SEQ option

`SEQ` は TCP の sequence number の話題で、sequence number を書き換える必要が生じた場合にのみ必要となるようです。
sequence number の書き換えが必要になるのは、L4 より上位のレイヤのデータを書き換えてサイズが変更された場合です。
アプリケーションに依存した何かをしない限りは無視できるはずですが、確認します。

以下の構造体が利用されているようです。

```c:net/netfilter/ipvs/ip_vs_sync.c
struct ip_vs_sync_conn_options {
        struct ip_vs_seq        in_seq;         /* incoming seq. struct */
        struct ip_vs_seq        out_seq;        /* outgoing seq. struct */
};
```


```c:include/net/ip_vs.h
/* Delta sequence info structure
 * Each ip_vs_conn has 2 (output AND input seq. changes).
 * Only used in the VS/NAT.
 */
struct ip_vs_seq {
        __u32                   init_seq;       /* Add delta from this seq */
        __u32                   delta;          /* Delta in sequence numbers */
        __u32                   previous_delta; /* Delta in sequence numbers
                                                 * before last resized pkt */
};
```

どこで `struct ip_vs_seq` が変更されるのか確認します。

```
ip_vs_in()
  ip_vs_nat_xmit()
    tcp_dnat_handler() <= ここで cp->app != NULL の判定
      ip_vs_app_pkt_in()
        app_tcp_pkt_in()
          vs_fix_seq()
          vs_fix_ack_seq()
```

`cp` は `struct ip_vs_conn` です。長いので抜粋します。

```c:include/net/ip_vs.h
/* IP_VS structure allocated for each dynamically scheduled connection */
struct ip_vs_conn {
// 省略
        /* Note: we can group the following members into a structure,
         * in order to save more space, and the following members are
         * only used in VS/NAT anyway
         */
        struct ip_vs_app        *app;           /* bound ip_vs_app object */
        void                    *app_data;      /* Application private data */
        struct ip_vs_seq        in_seq;         /* incoming seq. struct */
        struct ip_vs_seq        out_seq;        /* outgoing seq. struct */
// 省略
};
```

`struct ip_vs_app` を見ます。こちらも長いので抜粋します。

```c
/* The application module object (a.k.a. app incarnation) */
struct ip_vs_app {
        struct list_head        a_list;         /* member in app list */
        int                     type;           /* IP_VS_APP_TYPE_xxx */
        char                    *name;          /* application module name */
        __u16                   protocol;
// 省略
};
```

`IP_VS_APP_TYPE` を grep してみると FTP だけが見つかります。

```c
#define IP_VS_APP_TYPE_FTP      1
```

現状の `IPVS` においては FTP だけ考慮すれば良さそうです。
FTP `(ip_vs_ftp)` を使わなければ sequence number のオプションは無視できます。

#### PE_NAME, PE_DATA option

`PE` は `Persistent Engine` のことで、より複雑な処理に基づいてパケットの転送先を決められる仕組みです。

- [IPVS: SIP Persistence Engine  [LWN.net]](https://lwn.net/Articles/399571/)

現在は SIP を対象とした実装しかないため、SIP `(ip_vs_pe_sip)` を使わない場合は無視できます。



## 実証

ようやく情報が集まりましたので、実証してみます。

ところで `State` は 1:1 にマッピングできることが分かりましたが、
`SYN_SENT` や `FIN_WAIT` などの `State` のセッションは手動でパケット生成をしている間に
次のパケットが着信して `State` が変化し、Master sync daemon から更新パケットが飛ぶことが考えられます。

後から投げた `State` で上書きされるようなので、すぐ変わってしまうような `State` は無視して、ESTABLISHED だけを対象にすることにします。

まとめると

- `State` は ESTABLISHED のみ
- `Optional Parameter` は対応しない
 - `SEQ option`
 - `PE (Persistent Engine) option`
- FWM は対応しない
- `Flags` はちょっと怪しいけど入れてみる

という方針で、やってみました。
https://github.com/albatross0/ipvssync

[libnl](https://www.infradead.org/~tgr/libnl/) を使っているので、CentOS 7 の場合は `libnl-devel` と `libnl3-devel` パッケージを入れます。
実行前に `ip_vs` モジュールをロードしている必要があります。

```shell-session:ipvssyncコマンド作成
# yum install -y libnl-devel libnl3-devel
# git clone https://github.com/albatross0/ipvssync
# cd ipvssync
# gcc -g -Wall -lnl-3 -o ipvssync libipvs/libipvs.c ipvssync.c
# modprobe ip_vs
# ./ipvssync
Master sync daemon is not running
```

`ipvssync` コマンドを実行すると ESTABLISHED なセッションの sync message を送信します。
Master sync daemon が動作している場合は Master sync daemon の情報をもとに `Sync ID` などを収集できますが、
作業中は Master sync daemon のパケットと `ipvssync` コマンドのパケットが飛ぶとややこしいので、Master sync daemon は停止して引数で `Sync ID` などを渡すようにします。

Virtual Service は以下のように設定しました。
(Master sync daemon はいませんが、便宜上 Master と呼びます)

```shell-session:実証環境のIPVS設定
## Master のロードバランサ
# ip a add dev eth0 192.168.37.80/32
# ipvsadm -A -t 192.168.37.80:80 -s rr
# ipvsadm -a -t 192.168.37.80:80 -r 192.168.37.111:80
# ipvsadm -a -t 192.168.37.80:80 -r 192.168.37.112:80
# ipvsadm -Ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
  -> RemoteAddress:Port           Forward Weight ActiveConn InActConn
TCP  192.168.37.80:80 rr
  -> 192.168.37.111:80            Route   1      0          0
  -> 192.168.37.112:80            Route   1      0          0

## Backup のロードバランサ
# ipvsadm -A -t 192.168.37.80:80 -s rr
# ipvsadm -a -t 192.168.37.80:80 -r 192.168.37.111:80
# ipvsadm -a -t 192.168.37.80:80 -r 192.168.37.112:80
# ipvsadm --start-daemon backup --syncid 15
# ipvsadm -Ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
  -> RemoteAddress:Port           Forward Weight ActiveConn InActConn
TCP  192.168.37.80:80 rr
  -> 192.168.37.111:80            Route   1      0          0
  -> 192.168.37.112:80            Route   1      0          0
```

NAT 方式にすると `Flags` が `IP_VS_CONN_F_MASQ (0x0000)` になり、セットできているのか分からないので Direct Routing 方式にして `IP_VS_CONN_F_DROUTE (0x0003)` となるようにしています。
Master sync daemon は動かさず、Backup sync daemon だけ動かしておきます。

クライアントを用意して TCP コネクションを確立した後、Master 側で `ipvssync` コマンドを実行します。
Backup sync daemon が sync message を受け取ってセッションテーブルが更新されることを確認します。
Master sync daemon がいれば daemon に状態を問い合わせて `Sync ID` やマルチキャストするインタフェース名が分かるのですが、いないので引数で指定します。

```shell-session:ipvssyncコマンドによるセッション同期
## Master のロードバランサ
# ipvsadm -Lnc
IPVS connection entries
pro expire state       source             virtual            destination
TCP 14:43  ESTABLISHED 192.168.37.248:47860 192.168.37.80:80   192.168.37.112:80
## マルチキャスト用インタフェースとして eth0 を使い、Sync ID は 15 をセット
# ./ipvssync -f -i eth0 -n 15
Master sync daemon is not running
1 sessions are processed

## Backup のロードバランサ
# ipvsadm -Lnc
IPVS connection entries
pro expire state       source             virtual            destination
TCP 14:38  ESTABLISHED 192.168.37.248:47860 192.168.37.80:80   192.168.37.112:80
```

無事セッションテーブルが更新されたようです。

続いて、バックアップ側に生成されるセッションが本当に使えるかを試してみます。
クライアントは `HTTP Keep-Alive` を有効にして、同じ TCP コネクション上でリクエストを投げ続けておいて、手動で VIP を Backup 側に切り替えてみます。
セッションがうまく利用できなければ、通信は切れてしまいます。


```shell-session:クライアント側の実行コマンド
$ while :; do echo -ne "GET / HTTP/1.1\r\nHost: 192.168.37.80\r\n\r\n"; >&2 echo "---";
sleep 30; done | nc 192.168.37.80 80 | grep '^[A-Z]'
---
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Tue, 05 Dec 2017 11:12:15 GMT
Content-Type: text/html
Content-Length: 3700
Last-Modified: Wed, 18 Oct 2017 08:08:18 GMT
Connection: keep-alive
ETag: "59e70bf2-e74"
Accept-Ranges: bytes
---
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Tue, 05 Dec 2017 11:12:45 GMT
Content-Type: text/html
Content-Length: 3700
Last-Modified: Wed, 18 Oct 2017 08:08:18 GMT
Connection: keep-alive
ETag: "59e70bf2-e74"
Accept-Ranges: bytes
```

切り替えは次の手順で実施します。(sleep 30 の間に実行します)

- Master 側で ipvssync コマンドにより手動セッション同期
- Master 側から ip コマンドで VIP を削除
- Backup 側に   ip コマンドで VIP を付与
- Backup 側で arping コマンドにより GARP (Gratuitous ARP) 送信

```shell-session:切り替え実行
## Master (-d で同期するセッションの情報を表示します)
# ./ipvssync -f -i eth0 -n 15 -d | grep -E 'dump|process'
Master sync daemon is not running
1 sessions are processed
dump: TCP client=192.168.37.248:48012, virt=192.168.37.80:80, dest=192.168.37.112:80
# ip a del dev eth0 192.168.37.80/32

## Backup
# ip a add dev eth0 192.168.37.80/32
# arping -c 1 -I eth0 -s 192.168.37.80 192.168.37.80
```

```shell-session:切り替え後のクライアントの出力
---
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Tue, 05 Dec 2017 11:13:15 GMT
Content-Type: text/html
Content-Length: 3700
Last-Modified: Wed, 18 Oct 2017 08:08:18 GMT
Connection: keep-alive
ETag: "59e70bf2-e74"
Accept-Ranges: bytes
---
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Tue, 05 Dec 2017 11:13:45 GMT
Content-Type: text/html
Content-Length: 3700
Last-Modified: Wed, 18 Oct 2017 08:08:18 GMT
Connection: keep-alive
ETag: "59e70bf2-e74"
Accept-Ranges: bytes
```

クライアント側のパケットダンプの解析結果を載せておきます。

![httpclient_marked.png](https://qiita-image-store.s3.amazonaws.com/0/44206/3077749e-14ca-46ef-3cf1-f8a5b8566564.png)

ちょっと見づらいですが、以下のことが分かります。

- GARP の前後で HTTP GET の宛先 MAC アドレスが変わっている
- HTTP 応答の送信元 MAC アドレスは GARP の前後で変わっていない
- HTTP の通信は同一 TCP コネクション上で継続できている

実証環境は同一 L2 セグメント内なので、HTTP 応答の送信元 MAC アドレスはリアルサーバのものです。

ということで、無事通信を継続することができました。
手動でセッション同期する、というアイディアは実現可能でした。




# その他

ロードバランサで SNAT もしている場合は `sync daemon` で IPVS のセッション情報を同期するだけでは不充分で、`conntrackd` で conntrack の情報も同期が必要になります。