IPVS について

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

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

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

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

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

IPVS セッションの同期

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

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

セッションテーブルの例
# 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 はデフォルトで VRRPVRID と同じ値を使用します。

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]
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 側はそのパケットを利用して自身のセッションテーブルを更新します。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

IPVS sync message パケットの構造

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

sync_message_from_master_daemon.png

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

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

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

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

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 0Version 1 があり、Version 1 のパケットは Version 0 として処理するサーバにとっては Count Conns = 0 であるように見えるため、受信してもドロップするようです。
さきほど Wireshark で見た Connection Count: 0Version 0 の dissector しか実装されていないためだったようです。

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

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

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

IPVS sync connection フォーマット

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

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 らしいものは表示されません。どういうものなのか、調査が必要です。
Stateipvsadm -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() 内でこれをセットしている箇所があります。

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->v4struct ip_vs_sync_v4 です。ver_size のコメントを見るに、 Version 0 という扱いのようです。
SVER_MASK0x0fff と定義されているので、とりあえず同じようにして 0 をセットすれば良さそうです。

Flags, fwmark

Flagsipvsadm -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 です。

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 を読んでいるだけです。

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

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

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;
}
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,
};
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));
}
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,
};
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() から返されるようです。

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 の実装を見ます。

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,
};
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] : "?";
}
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!",
};
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() の以下の部分のようです。

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 には sequencePE がありそうです。
他にありそうか、define しているところを grep してみます。

#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 は判定にしか使われておらず、SEQPE_NAME, PE_DATA を見ればよさそうです。

SEQ option

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

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

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 */
};
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()

cpstruct ip_vs_conn です。長いので抜粋します。

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 を見ます。こちらも長いので抜粋します。

/* 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 だけが見つかります。

#define IP_VS_APP_TYPE_FTP      1

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

PE_NAME, PE_DATA option

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

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

実証

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

ところで State は 1:1 にマッピングできることが分かりましたが、
SYN_SENTFIN_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 を使っているので、CentOS 7 の場合は libnl-devellibnl3-devel パッケージを入れます。
実行前に ip_vs モジュールをロードしている必要があります。

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 と呼びます)

実証環境の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 方式にすると FlagsIP_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 やマルチキャストするインタフェース名が分かるのですが、いないので引数で指定します。

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 側に切り替えてみます。
セッションがうまく利用できなければ、通信は切れてしまいます。

クライアント側の実行コマンド
$ 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) 送信
切り替え実行
## 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
切り替え後のクライアントの出力
---
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

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

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

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

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

その他

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