第 9-3 章 Linux haproxy 服务高级配置
haproxy 是一个非常强大的服务,特别是在高并发的场景下,是一个非常不错的选择;前两篇针对 haproxy 的使用做了详细说明,让 haproxy 在生产环境下优雅地跑起来绝不在话下;下面我们需要对它的一些重要功能进一步剖析一下。
9.1、haproxy cookie
http 是无状态协议,任何一个七层的 http 负载均衡器,都应该具备一个功能:会话保持。会话保持是保证客户端对动态应用程序正确请求的基本要求。
如果反向代理的后端提供的是 "有状态" 的服务或协议时,必须保证请求过一次的客户端能被引导到同一服务端上。只有这样,服务端才能知道这个客户端是它曾经处理过的,能查到并获取到和该客户端对应的上下文环境(session 上下文),有了这个 session 环境才能继续为该客户端提供后续的服务。
- 简单举个例子,客户端 A 向服务端 B 请求将 C 商品加入它的账户购物车,加入成功后,服务端 B 会在某个缓存中记录下客户端 A 和它的商品 C,这个缓存的内容就是 session 上下文环境。
- 而识别客户端的方式一般是设置 session ID(如 PHPSESSID、JSESSIONID),并将其作为 cookie 的内容交给客户端。
- 客户端 A 再次请求的时候(比如将购物车中的商品下订单)只要携带这个 cookie,服务端 B 就可以从中获取到 session ID 并找到属于客户端 A 的缓存内容,也就可以继续执行下订单部分的代码。
- 假如这时使用负载均衡软件对客户端的请求进行负载,如果这个负载软件只是简单地进行负载转发,就无法保证将客户端 A 引导到服务端 B,可能会引导到服务端 X、服务端 Y,但是 X、Y 上并没有缓存和客户端 A 对应的 session 内容,当然也无法为客户端 A 下订单。
因此,反向代理软件必须具备将客户端和服务端 "绑定" 的功能,也就是所谓的提供会话保持,让客户端 A 后续的请求一定转发到服务端 B 上。
haproxy 提供了 3 种实现会话保持的方式:
- 源地址 hash;
- 设置 cookie;
- 会话粘性表 stick-table;
9.1.1、haproxy 设置 cookie 的几种方式
设置 cookie 的方式是通过在配置文件中使用 cookie 指令进行配置的。由于 haproxy 设置 cookie 的目的是为了将某客户端引导到之前为其服务过的后端服务器上,简单地说,就是和后端某服务器保持联系,因此 cookie 指令不能设置在 frontend 段落。
首先看一个设置 cookie 的示例:
backend dynamic_servers
cookie app_cook insert nocache
server app1 192.168.122.102:80 cookie server1
server app2 192.168.122.103:80 cookie server2
-
这个示例配置中,
cookie
指令中指定的是 insert 命令,表示在将响应报文交给客户端之前,先插入一个属性名为 "app_cook" 的 cookie,这个 cookie 在响应报文的头部将独占一个 "Set-Cookie" 字段(因为是插入新 cookie),而 "app_cook" 只是 cookie 名称,它的值是由 server 指令中的 cookie 选项指定的,这里是 "server1" 或 "server2"。 -
因此,如果这个请求报文分配给后端 app2 时,响应给客户端的响应报文中 haproxy 设置的 "Set-Cookie" 字段的样式为:
Set-Cookie:app_cook=server2; path=/
除了 insert 命令,cookie 指令中还支持 rewrite 和 prefix 两种设置 cookie 的方式,这三种 cookie 的操作方式只能三选一。此外,还提供一些额外对 cookie 的功能设置。
首先看看指令的语法:
cookie <name> [ rewrite | insert | prefix ] [ indirect ] [ nocache ]
[ postonly ] [ preserve ] [ httponly ] [ secure ]
[ domain <domain> ]* [ maxidle <idle> ] [ maxlife <life> ]
本文详细分节讨论 rewrite、insert、prefix 的行为,并在讨论它们的时候会穿插说明 indirect、nocache 和 preserve 的行为,如果需要了解其他选项,请自翻官方手册。
下图是后文实验时使用的环境:
其中在后端提供的 index.php 内容大致如下,主要部分是设置了名为 PHPSESSID
的 cookie。
<h1>response from webapp 192.168.122.102/103</h1>
<?php
session_start();
echo "Server IP: "."<font color=red>".$_SERVER['SERVER_ADDR']."</font>"."<br>";
echo "Server Name: "."<font color=red>".$_SERVER['SERVER_NAME']."</font>"."<br>";
echo "SESSIONNAME: "."<font color=red>".session_name()."</font>"."<br>";
echo "SESSIONID: "."<font color=red>".session_id()."</font>"."<br>";
?>
9.1.1.1、cookie insert
cookie <name> [ rewrite | insert | prefix ] [ indirect ] [ nocache ]
[ postonly ] [ preserve ] [ httponly ] [ secure ]
[ domain <domain> ]* [ maxidle <idle> ] [ maxlife <life> ]
[ dynamic ] [ attr <value> ]*
insert This keyword indicates that the persistence cookie will have to
be inserted by HAProxy in server responses if the client did not
already have a cookie that would have permitted it to access this
server. When used without the "preserve" option, if the server
emits a cookie with the same name, it will be removed before
processing. For this reason, this mode can be used to upgrade
existing configurations running in the "rewrite" mode. The cookie
will only be a session cookie and will not be stored on the
client's disk. By default, unless the "indirect" option is added,
the server will see the cookies emitted by the client. Due to
caching effects, it is generally wise to add the "nocache" or
"postonly" keywords (see below). The "insert" keyword is not
compatible with "rewrite" and "prefix".
- insert 参数的意思如下:
- haproxy 将在客户端没有 cookie 时(比如第一次请求),在响应报文中插入一个 cookie。
- 当没有使用关键词 "preserve" 选项时,如果后端服务器设置了一个和此处名称相同的 cookie,则首先删除服务端设置的 cookie。
- 该 cookie 只能作为会话保持使用,无法持久化到客户端的磁盘上(因为 haproxy 设置的 cookie 没有 maxAge 属性,无法持久保存,只能保存在浏览器缓存中)。
- 默认情况下,除非使用了 "indirect" 选项,否则服务端可以看到客户端请求时的所有 cookie 信息。
- 由于缓存的影响,建议加上 "nocache" 或 "postonly" 选项。
下面使用例子来解释 insert 的各种行为。
在 haproxy 如下配置后端。
backend DynamicGroup
cookie app_cook insert nocache
server app1 192.168.122.102:80 cookie app_server1
server app2 192.168.122.103:80 cookie app_server2
当使用浏览器第一次访问 http://192.168.122.101/index.php
时,响应结果和响应首部内容如下图:
- 从图中可以知道,这次浏览器的请求分配给了 app2,而且响应首部中有两个 "Set-Cookie" 字段,其中带有 PHPSESSID 的 cookie 是 app2 服务器自身设置的,另一个是 haproxy 设置的,其名和其值为 "app_cook=app_server2"。
如果客户端再次访问(不关闭浏览器,cookie 缓存还在),请求头中将携带该 cookie,haproxy 发现了该 cookie 中 "app_cook=app_server2" 部分,知道这个请求要交给 app_server2 这个后端。如下图:
这样就实现了会话保持,保证被处理过的客户端能被分配到同一个后端应用服务器上。
注意,客户端在第一次收到响应后就会把 cookie 缓存下来,以后每次 http://192.168.122.101/index.php
(根据域名进行判断)都会从缓存中取出该 cookie 放进请求首部。这样 haproxy 一定会将其分配给 app_server2,除非 app_server2 下线了。
但即使如此,客户端还是会携带该 cookie,只不过 haproxy 判断 app_server2 下线后,就为客户端重新分配 app_server1,并设置 "app_cook=app_server1",该 cookie 会替换客户端中的 "app_cook=app_server2"。下图是 app2 下线后分配给 app1 的结果:
- 注意,即使分配给了 app1,PHPSESSID 也不会改变(即 app1 设置的 PHPSESSID 无效),因为 haproxy 判断出这个重名 cookie,会删除 app1 设置的 PHPSESSID。因此上图中的 PHPSESSID 值和之前分配给 app2 时的 PHPSESSID 是一样的。
- 这样一来,app1 不是就无法处理该客户端的请求了吗?确实如此,但没办法,除非后端设置了 session 共享。
如果将配置文件中的 cookie 名称也设置为 PHPSESSID,即后端应用服务器和此处设置的 cookie 名称相同,那么 haproxy 将首先将后端的 PHPSESSID 删除,然后使用自己的值发送给客户端。也就是说,此时将只有一个 "Set-Cookie" 字段响应给客户端。
backend DynamicGroup
cookie PHPSESSID insert nocache
server app1 192.168.122.102:80 cookie app_server1
server app2 192.168.122.103:80 cookie app_server2
- 因此,在 cookie 指令中绝对不能设置 cookie 名称和后端的 cookie 名称相同,否则后端就相当于 "盲人"。例如此处的 PHPSESSID,此时后端虽然认识 PHPSESSID 是自己发送出去的 cookie 名称,但是无法获取 ID 为 "app_server2" 的 session 上下文。
- 如果不配合 "indirect" 选项,服务端可以看到客户端请求时的所有 cookie 信息。如果配合 "indirect" 选项,则 haproxy 在将请求转发给后端时,将删除自己设置的 cookie,使得后端只能看到它自己的 cookie,这样对后端来说,整个过程是完全透明的,它不知道前面有负载均衡软件。
重新修改 haproxy 的 cookie 指令,并修改 nginx 配置文件中日志格式,在其中加上 "$http_cookie" 变量,它表示请求报文中的 cookie 信息。
# haproxy
cookie app_cook insert nocache
# nginx
log_format main '$http_cookie $remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
- 客户端再次访问时,nginx 的日志中将记录以下信息(只贴出了前几个字段)。
PHPSESSID=0n2m66kr6tbpmqftcljlvjk3i7; app_cook=app_server1 192.168.122.101
- 加上 "indirect" 选项,再测试。
cookie app_cook insert indirect nocache
- 结果如下:
PHPSESSID=0n2m66kr6tbpmqftcljlvjk3i7 192.168.122.101
如果 insert 关键字配合 "preserve" 关键字,那么当后端设置了cookie 时,haproxy 将强制保留该 cookie,不做任何修改。也就是说,如果将 haproxy 的 cookie 名称也设置为 PHPSESSID,那么客户端第一次请求时收到的响应报文中将只有一个 "Set-Cookie" 字段,且这个字段的值是后端服务器设置的,和 haproxy 无关。
当客户端和 HAProxy 之间存在缓存时,建议将 insert 配合 nocache 一起使用,因为 nocache 确保如果需要插入 cookie,则可缓存页面将被标记为不可缓存。这一点很重要,因为如果所有 cookie 都添加到可缓存的页面上,则所有客户都将从中间的缓存层(如 cdn 端的缓存层)获取页面,并且将共享同一个 Cookie,从而导致某台后端服务器接收的流量远远超过其他后端服务器。
9.1.1.2、cookie prefix
prefix This keyword indicates that instead of relying on a dedicated
cookie for the persistence, an existing one will be completed.
This may be needed in some specific environments where the client
does not support more than one single cookie and the application
already needs it. In this case, whenever the server sets a cookie
named <name>, it will be prefixed with the server's identifier
and a delimiter. The prefix will be removed from all client
requests so that the server still finds the cookie it emitted.
Since all requests and responses are subject to being modified,
this mode doesn't work with tunnel mode. The "prefix" keyword is
not compatible with "rewrite" and "insert". Note: it is highly
recommended not to use "indirect" with "prefix", otherwise server
cookie updates would not be sent to clients.
-
haproxy 将在已存在的 cookie(例如后端应用服务器设置的)上添加前缀 cookie 值,这个前缀部分是 server 指令中的 cookie 设置的,代表的是服务端标识符。
-
在客户端再次访问时,haproxy 将会自动移除这部分前缀,使得服务端只能看到它自己发出的 cookie。在一些特殊环境下,客户端不支持多个 "Set-Cookie" 字段,这时可以使用 prefix。
-
使用 prefix 的时候,cookie 指令设置的 cookie 名必须和后端设置的 cookie 一样(在本文的环境中是 PHPSESSID),否则 prefix 模式下的 haproxy 不会对响应报文做任何改变。
backend DynamicGroup
cookie PHPSESSID prefix
server app1 192.168.122.102:80 cookie app_server1
server app2 192.168.122.103:80 cookie app_server2
如下图:
- 从后端 nginx 上的日志上查看 haproxy 转发过来的请求,可以看到前缀已经被 haproxy 去掉了。
PHPSESSID=k9cno2au088uhnm2na4rk99hv7 192.168.122.101
9.1.1.3、cookie rewrite
rewrite This keyword indicates that the cookie will be provided by the
server and that HAProxy will have to modify its value to set the
server's identifier in it. This mode is handy when the management
of complex combinations of "Set-cookie" and "Cache-control"
headers is left to the application. The application can then
decide whether or not it is appropriate to emit a persistence
cookie. Since all responses should be monitored, this mode
doesn't work in HTTP tunnel mode. Unless the application
behavior is very complex and/or broken, it is advised not to
start with this mode for new deployments. This keyword is
incompatible with "insert" and "prefix".
-
当后端服务器设置了 cookie 时,使用 rewrite 模式时,haproxy 将重写该 cookie 的值为后端服务器的标识符。
-
当应用程序需要同时考虑 "Set-Cookie" 和 "Cache-control" 字段时,该模式非常方便,因为应用程序可以决定是否应该设置一个为了保持会话的 cookie。除非后端应用程序的环境非常复杂,否则不建议使用该模式。
-
同样,rewrite 模式下的 haproxy 设置的 cookie 必须和后端服务器设置的 cookie 名称一致,否则不会做任何改变。
backend DynamicGroup
cookie PHPSESSID rewrite
server app1 192.168.122.102:80 cookie app_server1
server app2 192.168.122.103:80 cookie app_server2
结果如下图:
-
但是,当客户端持着 "PHPSESSID=app_server1" 再去请求服务器时,haproxy 将其分配给 app1,app1 此时收到的 cookie 将是重写后的,但是 app1 根本就不认识这个 cookie,后面的代码可能因此而失去逻辑无法进行正确处理。
-
后端 nginx 的日志如下:
PHPSESSID=app_server1 192.168.122.101
9.1.2、haproxy cookie 保持或忽略会话
在 haproxy 中,haproxy 会监控、修改、增加 cookie,这都是通过内存中的 cookie 表实现的。
cookie 表中记录了它自己增、改的 cookie 记录,包括 cookie 名和对应 server 的 cookie 值,通过这个 cookie 记录,haproxy 就能知道请求该交给哪个后端。
如图所示,当 haproxy 成功修改了响应报文中的 cookie 时,将在 cookie 表中插入一条记录,这条记录是维持会话的依据。
其实,通过 cookie 表保持和后端的会话只是默认情况,haproxy 允许 "即使使用了 cookie 也不进行会话绑定" 的功能。这可以通过 ignore-persist
指令来实现。当满足该指令的要求时,表示不将该 cookie 插入到 cookie 表中,因此无法实现会话保持,即使 haproxy 设置了 cookie 也没用。
例如,在 backend 中指定如下配置:
backend DynamicGroup
acl url_static path_beg /static /images /img /css
acl url_static path_end .gif .png .jpg .css .js
ignore-persist if url_static
cookie app_cook insert nocache
server app1 192.168.122.102:80 cookie app_server1
server app2 192.168.122.103:80 cookie app_server2
- 这表示 uri 满足 acl 中指定的静态网页时,将不进行会话保持。
- 与
ignore-persist
相对的是force-persist
,但不建议使用该选项,因为它和option redispatch
冲突。
9.2、haproxy stick table
stick table 是 haproxy 的一个非常优秀的特性,这个表里面存储的是 stickiness 记录,stickiness 记录了客户端和服务端 1:1 对应的引用关系。通过这个关系,haproxy 可以将客户端的请求引导到之前为它服务过的后端服务器上,也就是实现了会话保持的功能。这种记录方式,俗称会话粘性(stickiness),即将客户端和服务端粘连起来。
stick table 实现会话粘性的过程如下图:
-
stick table 中使用 key/value 的方式映射客户端和后端服务器,key 是客户端的标识符,可以使用客户端的源 ip(50字节)、cookie 以及从报文中过滤出来的部分 String。value 部分是服务端的标识符。
-
除了存储 key/value 实现最基本的粘性,stick table 还可以额外存储每个 stickiness 记录对应的状态统计数据。比如 stickiness 记录 1 目前建立了多少和客户端的连接、平均建立连接的速度是多少、流入流出了多少字节的数据、建立会话的数量等等。
-
stick table 可以在 "双主模型" 下进行复制(replication)。只要设置好对端 haproxy 节点,haproxy 就会自动将新插入的、刚更新的记录通过 TCP 连接推送到对端节点上。
- 这样一来,粘性记录不会丢失,即使某 haproxy 节点出现了故障,其他节点也能将客户端按照粘性映射关系引导到正确的后端服务器上。
- 而且每条 stickiness 记录占用空间都很小(平均最小 50 字节,最大 166 字节,由是否记录额外统计数据以及记录多少来决定占用空间大小),使得即使在非常繁忙的环境下在几十个节点之间推送都不会出现压力瓶颈和网络阻塞(可以按节点数量、stickiness 记录的大小和平均并发量来计算每秒在网络间推送的数据流量)。
-
stick table 还可以在 haproxy 重启时,在新旧两个进程间进行复制,这是本地复制。
- 当 haproxy 重启时,旧 haproxy 进程会和新 haproxy 进程建立 TCP 连接,将其维护的 stick table 推送给新进程。
- 这样新进程不会丢失粘性信息,和其他节点也能最大程度地保持同步,使得其他节点只需要推送该节点重启过程中新增加的 stickiness 记录就能完全保持同步。
9.2.1、使用 stick table
下图是本文测试时的环境:
9.2.1.1、创建 stick table
首先看创建 stick table 的语法:
stick-table type {ip | integer | string [len <length>] | binary [len <length>]}
size <size> [expire <expire>] [nopurge] [peers <peersect>] [srvkey <srvkey>]
[store <data_type>]*
type ip | integer | string
:使用什么类型的 key 作为客户端标识符。可以是客户端的源 IP,可以是一个整数 ID 值,也可以是一段从请求报文或响应报文中匹配出来的字符串。size
:表中允许的最大 stickiness 记录数量。单位使用 k、m 和 g 表示,分别表示 1024、2^20 和 2^30 条记录。expire
:stickiness 记录的过期时长。当某记录被操作后,过了一段时间就会过期,过期的记录会自动从 stick table 中移除,释放表空间。nopurge
:默认情况下,当表满后,如果还有新的 stickiness 记录要插入进来,haproxy 会自动将一部分老旧的 stickiness 记录 flush 掉,以释放空间存储新纪录。指定 nopurge 后,将不进行 flush,只能通过记录过期来释放表空间,因此该选项必须配合 expire 选项同时使用。peers
:指定要将 stick table 中的记录 replication 到对端 haproxy 节点。store
:指定要存储在 stick table 中的额外状态统计数据。其中代表后端服务器的标识符 server ID(即 key/value 的 value 部分)会自动插入,无需显式指定。
注意,每个后端组只能建立一张 stick table,每个 stick table 的 id 或名称等于后端组名。例如在 backend StaticGroup
后端创建 stick table,则该表的 id 为 "StaticGroup"。也有特殊方法建立多张,但没有必要,可翻官方手册找方法。
例如,创建一个以源 IP 地址为 key 的 stick table,该表允许 100W 条记录,5 分钟的记录过期时长,并且不记录任何额外数据。
stick-table type ip size 1m expire 5m
-
这张表由于没有记录额外的统计数据,每条 stickiness 记录在内存中只占用 50 字节左右的空间,表满后整张表在内存中占用 50MB(2^20*50/1024/1024=50MB)。看上去很大,但检索速度是极快的,完全不用担心性能问题。
-
如果还要存储和客户端建立的连接数量计数器(conn_cnt),则:
stick-table type ip size 1m expire 5m store conn_cnt
- conn_cnt 占用 32 个 bit 位,即 4 字节,因此每条 stickiness 记录占用 54 字节,100W 条记录占用 54M 内存空间。
9.2.1.2、查看 stick table
haproxy 没有直接的接口可以显示 stick table 的相关信息,只能通过 stats socket
进行查看。该指令表示开启一个本地 unix 套接字监听 haproxy 的信息,通过这个套接字可以查看 haproxy 的很多信息,且能动态调整 haproxy 配置。
首先在 haproxy 的配置文件中开启 "stats socket" 状态信息,如下:
global
stats socket /var/run/haproxy.sock mode 600 level admin
stats timeout 2m
- 默认 stats timeout 的过期时长为 10s,建议设置长一点。上面还设置了 socket 的权限级别,表示能访问(600)这个套接字的人具有所有权限(admin)。
- level 还有两种权限级别更低一点的值 "read" 和 "operator"(默认),前者表示只有读取信息的权限,不能设置或删除、清空某些信息,后者表示具备读和某些设置权限。
本地套接字监听 haproxy 后,可以通过 "socat" 工具(socket cat,很强大的工具,在 epel 源中提供)从套接字来操作 haproxy。
# 方式一:直接传递要执行的操作给套接字
echo "help" | socat unix:/var/run/haproxy.sock -
# 方式二:进入交互式模式,然后在交互式模式下执行相关操作
socat readline unix:/var/run/haproxy.sock
如果要监控某些状态信息的实时变化,可以使用 watch
命令。
watch -n 1 '"echo show table" | socat unix:/var/run/haproxy.sock -'
haproxy 支持以下列出的所有操作命令:
[root@c79arm1 ~]# echo "help" | socat unix:/var/run/haproxy.sock -
The following commands are valid at this level:
add acl [@<ver>] <acl> <pattern> : add an acl entry
add map [@<ver>] <map> <key> <val> : add a map entry (payload supported instead of key/val)
clear acl [@<ver>] <acl> : clear the contents of this acl
clear counters [all] : clear max statistics counters (or all counters)
clear map [@<ver>] <map> : clear the contents of this map
clear table <table> [<filter>]* : remove an entry from a table (filter: data/key)
commit acl @<ver> <acl> : commit the ACL at this version
commit map @<ver> <map> : commit the map at this version
del acl <acl> [<key>|#<ref>] : delete acl entries matching <key>
del map <map> [<key>|#<ref>] : delete map entries matching <key>
disable agent : disable agent checks
disable dynamic-cookie backend <bk> : disable dynamic cookies on a specific backend
disable frontend <frontend> : temporarily disable specific frontend
disable health : disable health checks
disable server (DEPRECATED) : disable a server for maintenance (use 'set server' instead)
enable agent : enable agent checks
enable dynamic-cookie backend <bk> : enable dynamic cookies on a specific backend
enable frontend <frontend> : re-enable specific frontend
enable health : enable health checks
enable server (DEPRECATED) : enable a disabled server (use 'set server' instead)
get acl <acl> <value> : report the patterns matching a sample for an ACL
get map <acl> <value> : report the keys and values matching a sample for a map
get var <name> : retrieve contents of a process-wide variable
get weight <bk>/<srv> : report a server's current weight
operator : lower the level of the current CLI session to operator
prepare acl <acl> : prepare a new version for atomic ACL replacement
prepare map <acl> : prepare a new version for atomic map replacement
set dynamic-cookie-key backend <bk> <k> : change a backend secret key for dynamic cookies
set map <map> [<key>|#<ref>] <value> : modify a map entry
set maxconn frontend <frontend> <value> : change a frontend's maxconn setting
set maxconn global <value> : change the per-process maxconn setting
set maxconn server <bk>/<srv> : change a server's maxconn setting
set profiling <what> {auto|on|off} : enable/disable resource profiling (tasks,memory)
set rate-limit <setting> <value> : change a rate limiting value
set server <bk>/<srv> [opts] : change a server's state, weight, address or ssl
set severity-output [none|number|string]: set presence of severity level in feedback information
set table <table> key <k> [data.* <v>]* : update or create a table entry's data
set timeout [cli] <delay> : change a timeout setting
set weight <bk>/<srv> (DEPRECATED) : change a server's weight (use 'set server' instead)
show acl [@<ver>] <acl>] : report available acls or dump an acl's contents
show activity : show per-thread activity stats (for support/developers)
show backend : list backends in the current running config
show cache : show cache status
show cli level : display the level of the current CLI session
show cli sockets : dump list of cli sockets
show env [var] : dump environment variables known to the process
show errors [<px>] [request|response] : report last request and/or response errors for each proxy
show events [<sink>] [-w] [-n] : show event sink state
show fd [num] : dump list of file descriptors in use or a specific one
show info [desc|json|typed|float]* : report information about the running process
show map [@ver] [map] : report available maps or dump a map's contents
show peers [dict|-] [section] : dump some information about all the peers or this peers section
show pools : report information about the memory pools usage
show profiling [<what>|<#lines>|byaddr]*: show profiling state (all,status,tasks,memory)
show resolvers [id] : dumps counters from all resolvers section and associated name servers
show schema json : report schema used for stats
show servers conn [<backend>] : dump server connections status (all or for a single backend)
show servers state [<backend>] : dump volatile server information (all or for a single backend)
show sess [id] : report the list of current sessions or dump this exact session
show startup-logs : report logs emitted during HAProxy startup
show stat [desc|json|no-maint|typed|up]*: report counters for each proxy and server
show table <table> [<filter>]* : report table usage stats or dump this table's contents (filter: data/key)
show tasks : show running tasks
show threads : show some threads debugging information
show trace [<module>] : show live tracing state
shutdown frontend <frontend> : stop a specific frontend
shutdown session [id] : kill a specific session
shutdown sessions server <bk>/<srv> : kill sessions on a server
trace [<module>|0] [cmd [args...]] : manage live tracing (empty to list, 0 to stop all)
user : lower the level of the current CLI session to user
help [<command>] : list matching or all commands
prompt : toggle interactive mode with prompt
quit : disconnect
[root@c79arm1 ~]#
其中和 stick table 相关的命令有:
clear table : remove an entry from a table
set table [id] : update or create a table entry's data
show table [id]: report table usage stats or dump this table's contents
我们实验中 stick-table 配置如下:
# 查看一下 stick-table 设置
[root@c79arm1 ~]# grep -B 1 "stick-table" /etc/haproxy/haproxy.cfg
backend StaticGroup
stick-table type ip size 5k expire 1m
--
backend DynamicGroup
stick-table type ip size 5k expire 1m
[root@c79arm1 ~]#
[root@c79arm1 ~]# echo "show table" | socat unix:/var/run/haproxy.sock -
# table: DynamicGroup, type: ip, size:5120, used:0
# table: StaticGroup, type: ip, size:5120, used:0
[root@c79arm1 ~]#
注意:本文只是引入 stats socket
的操作方式,至于各命令的作用,参见官方手册:http://cbonte.github.io/haproxy-dconv/2.4/management.html#9.3
9.2.1.3、客户端源 IP 标识
配置文件部分内容如下:
frontend http-in
bind *:80
mode http
log global
capture request header Host len 20
capture request header Referer len 60
acl url_static path_beg -i /static /images /stylesheets
acl url_static path_end -i .jpg .jpeg .gif .png .ico .bmp .css .js
acl url_static path_end -i .html .htm .shtml .shtm .pdf .mp3 .mp4 .rm .rmvb .txt
acl url_static path_end -i .zip .rar .gz .tgz .bz2 .tgz
use_backend StaticGroup if url_static
default_backend DynamicGroup
backend StaticGroup
stick-table type ip size 5k expire 1m
stick on src
balance roundrobin
option http-keep-alive
http-reuse safe
option httpchk GET /index.html
http-check expect status 200
server staticsrv1 192.168.122.104:80 check rise 1 maxconn 5000
server staticsrv2 192.168.122.105:80 check rise 1 maxconn 5000
backend DynamicGroup
stick-table type ip size 5k expire 1m
stick on src
balance roundrobin
option http-server-close
option httpchk GET /index.php
http-check expect status 200
server appsrv1 192.168.122.102:80 check rise 1 maxconn 3000
server appsrv2 192.168.122.103:80 check rise 1 maxconn 3000
- 上面的配置中,设置了acl,当满足静态访问时,使用 StaticGroup 后端组,否则使用 DynamicGroup 后端组。
- 在两个后端组中,都设置了
stick-table
和stick on
,其中stick on
是存储指定内容,并在请求到达时匹配该内容,它的具体用法见后文。只有配置了stick on
后,haproxy 才能根据匹配的结果决定是否存储到 stick table 中,以及如何筛选待分派的后端。 - 总之,上面的两个后端组都已经指定了要向 stick table 中存储源 ip 地址作为 key。当客户端请求到达时,haproxy 根据调度算法分配一个后端,但请求交给后端成功后,Haproxy 立即向 stick table 表中插入一条 stickiness 记录。
- 当客户端请求再次到达时,haproxy 发现能匹配源 ip,于是按照该 stickiness 记录,将请求分配给对应的后端。
以下是分别使用两台机器测试 192.168.122.101/index.html
和 192.168.122.101/index.php
后,stick table 记录的数据。
[root@c79arm1 ~]# echo "show table DynamicGroup" | socat unix:/var/run/haproxy.sock -
# table: DynamicGroup, type: ip, size:5120, used:2
0x201effb0: key=192.168.122.1 use=0 exp=43604 server_id=1 server_key=appsrv1
0xffff78033970: key=192.168.122.106 use=0 exp=50021 server_id=2 server_key=appsrv2
[root@c79arm1 ~]# echo "show table StaticGroup" | socat unix:/var/run/haproxy.sock -
# table: StaticGroup, type: ip, size:5120, used:2
0x201ef980: key=192.168.122.1 use=0 exp=37314 server_id=1 server_key=staticsrv1
0xffff70033900: key=192.168.122.106 use=0 exp=50135 server_id=2 server_key=staticsrv2
- 其中 server_id 默认是从 1 自增的,它可以在 server 指令中用 "id" 选项进行显式指定。例如:
server appsrv1 192.168.122.102:80 id 123 check rise 1 maxconn 3000
9.2.1.4、客户端 cookie 标识
一般会话保持考虑的对象是应用程序服务器,因此此处我们忽略后端的静态服务器,只考虑 php 应用服务器。在 DynamicGroup 两个后端 server appsrv1 和 server appsrv2 的 index.php 中分别设置好 PHPSESSID 作为测试。例如:
<h1>response from webapp 192.168.122.102</h1>
<?php
session_start();
echo "Server IP: "."<font color=red>".$_SERVER['SERVER_ADDR']."</font>"."<br>";
echo "Server Name: "."<font color=red>".$_SERVER['SERVER_NAME']."</font>"."<br>";
echo "SESSIONNAME: "."<font color=red>".session_name()."</font>"."<br>";
echo "SESSIONID: "."<font color=red>".session_id()."</font>"."<br>";
?>
cookie 是 string 的一种特殊情况,因此创建 stick table 时,指定 type 为 string。以下是在 haproxy 上的配置:
backend DynamicGroup
stick-table type string len 32 size 5k expire 2m
stick on req.cook(PHPSESSID)
stick store-response res.cook(PHPSESSID)
balance roundrobin
option http-server-close
option httpchk GET /index.php
http-check expect status 200
server appsrv1 192.168.122.102:80 check rise 1 maxconn 3000
server appsrv2 192.168.122.103:80 check rise 1 maxconn 3000
stick store-response
指令表示从响应报文中匹配某些数据出来,然后存储到 stick table 中,此处表示截取响应报文中 "Set-Cookie" 字段中名为 "PHPSESSID" 的 cookie 名进行存储。stick on req.cook(PHPSESSID)
表示从请求报文的 "Cookie" 字段中匹配名为 PHPSESSID 的 cookie。如果能和存储在 stick table 中的 PHPSESSID 匹配成功,则表示该客户端被处理过,于是将其引导到对应的后端服务器上。严格地说,这里不是识别客户端,而是通过 PHPSESSID 来识别后端。
浏览器请求后发生会话粘性,在 haproxy 上查看 stick table:
[root@c79arm1 ~]# echo "show table DynamicGroup" | socat unix:/var/run/haproxy.sock -
# table: DynamicGroup, type: string, size:5120, used:1
0xffffb8027320: key=8bvq7fcvprj29oj6s1n0djokk5 use=0 exp=117654 server_id=2 server_key=appsrv2
[root@c79arm1 ~]#
- 注意,不要使用 curl 命令来测试,因为这里是根据 PHPSESSID 匹配的,curl 每次接收到响应后进程就直接退出了,无法缓存 cookie,因此 curl 每次请求都相当于一次新请求。
9.2.1.5、客户端 string 标识
上面的 cookie 是 string 的一种特殊用法。使用 string 筛选内容进行存储,灵活性非常大,可以通过它实现某些复杂、特殊的需求。
例如,从请求报文中截取 Host 字段的值作为 key 存储起来。
backend DynamicGroup
stick-table type string size 5k expire 2m
stick on req.hdr(Host)
balance roundrobin
option http-server-close
option httpchk GET /index.php
http-check expect status 200
server appsrv1 192.168.122.102:80 check rise 1 maxconn 3000
server appsrv2 192.168.122.103:80 check rise 1 maxconn 3000
找一台 linux 客户端使用 curl 进行测试,发现所有请求都将引导到同一后端服务器上。
[root@yidam ~]# for i in {1..8};do grep "response" <(curl 192.168.122.101/index.php 2> /dev/null);done
<h1>response from webapp 192.168.122.102</h1>
<h1>response from webapp 192.168.122.102</h1>
<h1>response from webapp 192.168.122.102</h1>
<h1>response from webapp 192.168.122.102</h1>
<h1>response from webapp 192.168.122.102</h1>
<h1>response from webapp 192.168.122.102</h1>
<h1>response from webapp 192.168.122.102</h1>
<h1>response from webapp 192.168.122.102</h1>
[root@yidam ~]#
查看 stick table 也只能看到一条记录,而且其 key 部分正是捕获到的 Host 字段的值。
[root@c79arm1 ~]# echo "show table DynamicGroup" | socat unix:/var/run/haproxy.sock -
# table: DynamicGroup, type: string, size:5120, used:1
0x1dbfa00: key=192.168.122.101 use=0 exp=65915 server_id=1 server_key=appsrv1
9.2.1.6、stick on、stick match、stick store
在前面 haproxy 的配置中出现过 stick on
和 stick store-response
,除此之外,还有两个指令 stick match
、stick store-request
。语法如下:
stick store-request <pattern> [table <table>] [{if | unless} <condition>]
stick store-response <pattern> [table <table>] [{if | unless} <condition>]
stick match <pattern> [table <table>] [{if | unless} <cond>]
stick on <pattern> [table <table>] [{if | unless} <condition>]
- 其中
stick store
指令是从请求或响应报文中截取一部分字符串出来,并将其作为 stickiness 的 key 存储到 stick table 中。例如:
# 截取响应报文中名为PHPSESSID的cookie作为key
stick store-response res.cook(PHPSESSID)
# 截取请求报文中Host字段的值作为key
stick store-request req.hdr(Host)
# 对请求的源ip地址进行匹配,若不是兄弟网络中的主机时,就写入stick table中,且该table名为DynamicGroup
stick store-request src table DynamicGroup if !my_brother
stick match
是将请求报文中的指定部分和 stick table 中的记录进行匹配。例如:
# 截取请求报文中名为PHPSESSID的cookie,去stick table中搜索是否存在对应的记录
stick match req.cook(PHPSESSID)
# 当源IP不是本机时,去DynamicGroup表中搜索是否有能匹配到源IP地址的记录
stick match src table DynamicGroup if !localhost
stick on
等价于stick store
+stick match
,是它们的简化写法。例如:
# 存储并匹配源IP地址
stick on src #1 = #2 + #3
stick match src #2
stick store-request src #3
# 存储并匹配源IP地址
stick on src table DynamicGroup if !localhost #1 = #2 + #3
stick match src table DynamicGroup if !localhost #2
stick store-request src table DynamicGroup if !localhost #3
# 存储并匹配后端服务器设置的PHPSESSID
stick on req.cook(PHPSESSID) #1 +#2 = #3 + #4
stick store-response res.cook(PHPSESSID) #2
stick match req.cook(PHPSESSID) #3
stick store-response res.cook(PHPSESSID) #4
9.2.1.7、stick table 统计状态信息
stick table 除了存储基本的粘性信息,还能存储额外的统计数据,这其实是 haproxy 提供的一种 "采样调查" 功能。它能采集的数据种类有以下几种:
每个 stickiness 记录中可以同时存储多个记录类型,使用逗号分隔或多次使用 store 关键字即可。但注意,后端服务器的 server id 会自动记录,其它所有额外信息都需要显式指定。
需要注意,每个 haproxy 后端组只能有一张 stick table,但却不建议统计太多额外的状态信息,因为每多存一个类型,意味着使用更多的内存。
如果存储所有上述列出的数据类型,需要 116 字节,100W 条记录要用 116M,这不是可以忽略的大小。此外还有 50M 的 key,共 166M。
例如下面的示例中,使用了通用计数器累计,并记录了每 30 秒内的平均连接速率。
stick-table type ip size 1m expire 5m store gpc0,conn_rate(30s)
9.3、haproxy stick table 复制
在上一节中,分析了 haproxy 的 stick table 特性和用法,其中特性之一也是很实用的特性是 stick table 支持在 haproxy 多个节点之间进行复制(replication)。
本文仅讨论如何配置实现 stick table 的复制功能,不考虑在什么环境下实现它,以及它的双主模型如何配置。
本文实验环境:
9.3.1、stick table 复制特性
- 只要设置好 haproxy 的节点组,haproxy 就会自动将新插入的、刚更新的 stickiness 记录通过 TCP 连接推送到节点组中的非本地节点上。
- 这样一来,stickiness 记录不会丢失,即使某 haproxy 节点出现了故障,其他节点也能将客户端按照粘性映射关系引导到正确的后端服务器上。
- 由于每条 stickiness 记录占用空间都很小(平均最小 50 字节,最大 166 字节,由是否记录额外统计数据以及记录多少来决定占用空间大小),使得即使在非常繁忙的环境下多个节点之间推送都不会出现压力瓶颈和网络阻塞(可以按节点数量、stickiness 记录的大小和平均并发量来计算每秒在网络间推送的数据流量)。
- stick table 复制不像被人诟病的 session 复制(copy),因为 session 复制的数据量比较大,而且是在各应用程序服务器之间进行的。而一个稍大一点的核心应用,提供服务的应用程序服务器数量都不会小,这样复制起来很容出现网络阻塞。
- 此外,stick table 还可以在 haproxy 重启时,在新旧两个进程间进行复制,这是本地复制。
- 当 haproxy 重启时,旧 haproxy 进程会和新 haproxy 进程建立 TCP 连接,将其维护的 stick table 推送给新进程。
- 这样新进程不会丢失粘性信息,和其他节点也能最大程度地保持同步,使得其他节点只需要推送该节点重启过程中新增加的 stickiness 记录就能完全保持同步。
- 如果后端使用了 session 共享,在大多数情况下没必要在代理层实现会话保持。如果此时使用 stick table,一般只是为了收集统计数据进行采样调查,但这样的状态统计数据无需在各节点之间进行复制。
9.3.2、定义 haproxy 节点成员
haproxy 提供了 peers
指令,用于定义节点组,peer
指令用于定义节点组中的成员。
例如,定义本测试环境的节点组:
peers my_peers # 节点组名
peer haproxy1 192.168.122.101:12138 # 定义对端名称,以及和对端建立tcp连接的端口,用于推送stickiness记录
peer haproxy2 192.168.122.106:12138
peer haproxy3 192.168.122.107:12138
然后在 stick-table
指令中引用节点组。例如:
stick-table type ip size 100k expire 5m peers my_peers
注意,应当让每个 haproxy 节点的节点组内容一致,然后使用 haproxy 命令的 "-L" 选项指定本地节点成员,这样方便管理每个节点和本地节点。
haproxy -D -L haproxy1 -f /etc/haproxy/haproxy.cfg
- 如果不指定 "-L",则在解析配置文件时将默认以主机名作为本地节点名进行解析,但很可能它不会定义在
peers
中,因此会报错。
因此,如果要实现 stick table 的复制,还想使用 sysV 或 systemctl 管理 haproxy 服务,需要修改这些服务管理脚本,在启动、重启和语法检查项上加上 "-L" 选项。例如,systemd 的 haproxy 服务管理脚本改为如下内容:
[Unit]
Description=Haproxy Service
After=syslog.target network.target
[Service]
Type=forking
ExecStart=/usr/local/haproxy/sbin/haproxy -L haproxy1 -f /etc/haproxy/haproxy.cfg
ExecStop=cd&&/usr/local/haproxy/sbin/&&pkill haproxy
[Install]
WantedBy=multi-user.target
注意:每个节点要么完全使用 sysV、systemctl 管理 haproxy 的启动、停止,要么完全使用 haproxy 命令手动管理服务的启动和停止,否则可能会出现记录不同步的现象。
9.3.3、完成配置
如下,是每个 haproxy 节点的配置文件内容。
global
log 127.0.0.1 local2
chroot /var/lib/haproxy
pidfile /var/run/haproxy.pid
maxconn 20000
user haproxy
group haproxy
daemon
stats socket /var/run/haproxy.sock mode 600 level admin
spread-checks 2
peers mypeers
peer haproxy1 192.168.122.101:12138
peer haproxy2 192.168.122.106:12138
peer haproxy3 192.168.122.107:12138
defaults
mode http
log global
option httplog
option dontlognull
option http-server-close
option forwardfor except 127.0.0.0/8
option redispatch
timeout http-request 2s
timeout queue 3s
timeout connect 1s
timeout client 10s
timeout server 2s
timeout http-keep-alive 10s
timeout check 2s
maxconn 18000
frontend http-in
bind *:80
mode http
log global
default_backend DynamicGroup
backend DynamicGroup
stick-table type string len 32 size 5k expire 5m peers mypeers
stick on req.cook(PHPSESSID)
stick store-response res.cook(PHPSESSID)
balance roundrobin
option http-server-close
option httpchk GET /index.php
option redispatch
http-check expect status 200
server appsrv1 192.168.122.102:80 check rise 1 maxconn 3000
server appsrv2 192.168.122.103:80 check rise 1 maxconn 3000
然后分别在 3 个节点上启动 haproxy 服务,分别在 haproxy1、haproxy2、haproxy3 上监控 stick table。
watch -n 1 'echo "show table DynamicGroup" | socat unix:/var/run/haproxy.sock -'
由于上面的配置文件中,stick table 中存储和匹配的记录是响应报文中名为 PHPSESSID 的 cookie,因此在浏览器上测试时,能实现会话粘性。
但如果使用 curl 命令进行测试,由于 curl 每次执行结束进程就退出,无法缓存 cookie,因此 curl 的每次请求都是新连接。此处正好利用 curl 这一点特性,来生成多个 stickiness 记录,方便观察 stick table 记录推送的情况。
找一台 Linux 主机,分别对三个 haproxy 节点进行 5 次 curl 请求。
ip1=192.168.122.101
ip2=192.168.122.106
ip3=192.168.122.107
for i in $ip1 $ip2 $ip3;do
for j in `seq 1 5`;do
curl http://$i/index.php &>/dev/null
usleep 500000
done
done
以下是截取自三个节点的 stick table 内容。
###################### haproxy1 ########################
# table: DynamicGroup, type: string, size:5120, used:15
0xffff88042cc0: key=61m6cpr7qkkcrst2m0fq85vs93 use=0 exp=217803 server_id=1 server_key=appsrv1
0xffff88042b60: key=672uvf5c9jb0ppb115b16glip5 use=0 exp=215246 server_id=1 server_key=appsrv1
0xffff88041820: key=6tlnotjd9rb8b9ln96cjul6vj4 use=0 exp=219861 server_id=1 server_key=appsrv1
0xffff84038e30: key=75t9mkmtfjcn6prv6vm7fq5bs1 use=0 exp=218317 server_id=2 server_key=appsrv2
0xffff8803d430: key=77akt5tv541usblqp09qvhcjk3 use=0 exp=214219 server_id=2 server_key=appsrv2
0xffff840388f0: key=9qo3ion9tubhd6hofv709vlrt7 use=0 exp=218833 server_id=1 server_key=appsrv1
0xffff88042a00: key=a39v8ntdn5u7sd66jbjns0b1j3 use=0 exp=215757 server_id=2 server_key=appsrv2
0xffff84038790: key=b5vl3bd68pr9ij2mha9329rg10 use=0 exp=216268 server_id=1 server_key=appsrv1
0xffff88042290: key=bmnob21gp6oq87qb03vpl0pu35 use=0 exp=216780 server_id=2 server_key=appsrv2
0xffff84037bc0: key=hhgj3ckk0jd6p6ivmkpqugv6p3 use=0 exp=213198 server_id=2 server_key=appsrv2
0xffff880407a0: key=k8knr6k0d6u7rln4f2dg85vju4 use=0 exp=212686 server_id=1 server_key=appsrv1
0xffff84038560: key=n4422bvflsqhdtlrhl6vkv9sa4 use=0 exp=219351 server_id=2 server_key=appsrv2
0xffff84039b10: key=osstpulqkcm9t0cfb8i2albdk3 use=0 exp=214732 server_id=1 server_key=appsrv1
0xffff84037c90: key=qn55ad64v6qm5jgvrq3mg6kd22 use=0 exp=213708 server_id=1 server_key=appsrv1
0xffff880412a0: key=svimc7pe9abamhmvmccune7j91 use=0 exp=217291 server_id=1 server_key=appsrv1
###################### haproxy2 ########################
# table: DynamicGroup, type: string, size:5120, used:15
0xffff80026c90: key=61m6cpr7qkkcrst2m0fq85vs93 use=0 exp=210178 server_id=1 server_key=appsrv1
0x25b01040: key=672uvf5c9jb0ppb115b16glip5 use=0 exp=207620 server_id=1 server_key=appsrv1
0xffff7403d430: key=6tlnotjd9rb8b9ln96cjul6vj4 use=0 exp=212236 server_id=1 server_key=appsrv1
0xffff7403eee0: key=75t9mkmtfjcn6prv6vm7fq5bs1 use=0 exp=210692 server_id=2 server_key=appsrv2
0xffff7403d500: key=77akt5tv541usblqp09qvhcjk3 use=0 exp=206594 server_id=2 server_key=appsrv2
0xffff7403e960: key=9qo3ion9tubhd6hofv709vlrt7 use=0 exp=211208 server_id=1 server_key=appsrv1
0xffff94027150: key=a39v8ntdn5u7sd66jbjns0b1j3 use=0 exp=208132 server_id=2 server_key=appsrv2
0xffff8c03c9f0: key=b5vl3bd68pr9ij2mha9329rg10 use=0 exp=208642 server_id=1 server_key=appsrv1
0xffff84026910: key=bmnob21gp6oq87qb03vpl0pu35 use=0 exp=209155 server_id=2 server_key=appsrv2
0xffff7403e610: key=hhgj3ckk0jd6p6ivmkpqugv6p3 use=0 exp=205573 server_id=2 server_key=appsrv2
0xffff7403d780: key=k8knr6k0d6u7rln4f2dg85vju4 use=0 exp=205061 server_id=1 server_key=appsrv1
0xffff7403e280: key=n4422bvflsqhdtlrhl6vkv9sa4 use=0 exp=211726 server_id=2 server_key=appsrv2
0xffff7403cb60: key=osstpulqkcm9t0cfb8i2albdk3 use=0 exp=207108 server_id=1 server_key=appsrv1
0xffff7403dfc0: key=qn55ad64v6qm5jgvrq3mg6kd22 use=0 exp=206083 server_id=1 server_key=appsrv1
0xffff88026910: key=svimc7pe9abamhmvmccune7j91 use=0 exp=209665 server_id=1 server_key=appsrv1
###################### haproxy3 ########################
# table: DynamicGroup, type: string, size:5120, used:15
0x3d5b1330: key=61m6cpr7qkkcrst2m0fq85vs93 use=0 exp=207767 server_id=1 server_key=appsrv1
0xffff94026c90: key=672uvf5c9jb0ppb115b16glip5 use=0 exp=205211 server_id=1 server_key=appsrv1
0xffff8c026910: key=6tlnotjd9rb8b9ln96cjul6vj4 use=0 exp=209826 server_id=1 server_key=appsrv1
0xffff94026910: key=75t9mkmtfjcn6prv6vm7fq5bs1 use=0 exp=208282 server_id=2 server_key=appsrv2
0xffff94033630: key=77akt5tv541usblqp09qvhcjk3 use=0 exp=204185 server_id=2 server_key=appsrv2
0xffff94026ac0: key=9qo3ion9tubhd6hofv709vlrt7 use=0 exp=208798 server_id=1 server_key=appsrv1
0xffff7c027b50: key=a39v8ntdn5u7sd66jbjns0b1j3 use=0 exp=205722 server_id=2 server_key=appsrv2
0xffff7c027d00: key=b5vl3bd68pr9ij2mha9329rg10 use=0 exp=206233 server_id=1 server_key=appsrv1
0xffff7c027ed0: key=bmnob21gp6oq87qb03vpl0pu35 use=0 exp=206745 server_id=2 server_key=appsrv2
0xffff94033eb0: key=hhgj3ckk0jd6p6ivmkpqugv6p3 use=0 exp=203163 server_id=2 server_key=appsrv2
0xffff94034430: key=k8knr6k0d6u7rln4f2dg85vju4 use=0 exp=202651 server_id=1 server_key=appsrv1
0xffff94035f90: key=n4422bvflsqhdtlrhl6vkv9sa4 use=0 exp=209316 server_id=2 server_key=appsrv2
0xffff90033790: key=osstpulqkcm9t0cfb8i2albdk3 use=0 exp=204698 server_id=1 server_key=appsrv1
0xffff94033de0: key=qn55ad64v6qm5jgvrq3mg6kd22 use=0 exp=203673 server_id=1 server_key=appsrv1
0xffff7c038cb0: key=svimc7pe9abamhmvmccune7j91 use=0 exp=207256 server_id=1 server_key=appsrv1
结果符合预期目标,每个表都有 15 条 stickiness,且它们的 key 和对应的 server_id 完全相同。