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 はデフォルトで VRRP
の VRID
と同じ値を使用します。
# ps auxww | grep 'ipv[s]'
root 13680 0.0 0.0 0 0 ? S 00:00 0:00 [ipvs-m:0:0]
# 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)
- IPVS Connection Synchronization
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 に食わせると、不思議な情報が表示されます。
このパケットは ipvsadm --start-daemon master --syncid 15
で起動した sync daemon
から送られたものです。
Connection Count が 0 になっていて、これは送ったセッション情報の数を示すと予想されるので、違和感があります。
Sync ID は daemon の起動時に指定したものが見えますが、解析されていないデータが多くあります。
Wireshark だけではわかりそうにないので、ここからは Linux のソースコードを参照しながらいきます。
バージョンは 4.14
です。
IPVS sync message ヘッダフォーマット
sync message のヘッダ構造を示したコメントがあるので、抜粋します。
/*
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 を確認します。
/*
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()
内でこれをセットしている箇所があります。
/* 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
です。
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
を読んでいるだけです。
#define CONN_PROC_FILE "/proc/net/ip_vs_conn"
また Linux カーネルに戻って、/proc/net/ip_vs_conn
がどのようにして表示されるのか調べます。
/*
* 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;
}
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,
};
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));
}
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,
};
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()
から返されるようです。
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
の実装を見ます。
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,
};
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] : "?";
}
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!",
};
/* 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()
の以下の部分のようです。
/* 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 してみます。
#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 より上位のレイヤのデータを書き換えてサイズが変更された場合です。
アプリケーションに依存した何かをしない限りは無視できるはずですが、確認します。
以下の構造体が利用されているようです。
struct ip_vs_sync_conn_options {
struct ip_vs_seq in_seq; /* incoming seq. struct */
struct ip_vs_seq out_seq; /* outgoing seq. struct */
};
/* 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
です。長いので抜粋します。
/* 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
PE
は Persistent Engine
のことで、より複雑な処理に基づいてパケットの転送先を決められる仕組みです。
現在は 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 を使っているので、CentOS 7 の場合は libnl-devel
と libnl3-devel
パッケージを入れます。
実行前に ip_vs
モジュールをロードしている必要があります。
# 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 と呼びます)
## 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
やマルチキャストするインタフェース名が分かるのですが、いないので引数で指定します。
## 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
クライアント側のパケットダンプの解析結果を載せておきます。
ちょっと見づらいですが、以下のことが分かります。
- GARP の前後で HTTP GET の宛先 MAC アドレスが変わっている
- HTTP 応答の送信元 MAC アドレスは GARP の前後で変わっていない
- HTTP の通信は同一 TCP コネクション上で継続できている
実証環境は同一 L2 セグメント内なので、HTTP 応答の送信元 MAC アドレスはリアルサーバのものです。
ということで、無事通信を継続することができました。
手動でセッション同期する、というアイディアは実現可能でした。
その他
ロードバランサで SNAT もしている場合は sync daemon
で IPVS のセッション情報を同期するだけでは不充分で、conntrackd
で conntrack の情報も同期が必要になります。