Jason Pan

无侵入服务流量复制实践

黄杰 / 2023-01-17


本文介绍查询服务迁移场景中,通过全流量复制,来检测新服务正确性和可用性。内容包括tcpcopy原理、无侵入部署、监控曲线与统计,以及过程中提高效率措施。相比功能测试、压力测试,能够更真实全面的检测新服务。

1. 背景

查询服务迁移上云(Koala)。

当前线上部署 4 个网关集群,每个网关集群监听2个端口的,分别是 {消息长度}+JSONJSON+0x00 的二进制协议,网关再根据请求中的 cred_id 字段,路由到具体的服务,共有服务100+,网关和服务之前采用 gRPC 协议。客户端通过 8 个 L5 发现这 4 个网关集群的服务并进行访问。

image-20230119150734779

Koala 南天门网关能够转发 HTTP 请求。在迁移到 Koala 之前,各个服务已经做了改造,改成接收 HTTP/JSON 协议,框架内部会将 HTTP/JSON 转成 PB 消息,使用之前的逻辑。网关也进行了改造,以接收原来两种二进制协议,转发后端协议由之前的 gRPC 改成 HTTP。

image-20230119150752305

当服务迁移完成,可以通过更新 L5 的方式逐渐将流量导入到新集群。

由于服务的前端分布很广泛,直接进行流量导入,很难及时感知错误;部分服务的请求量很低,再加之 L5 权重控制,短时间内难发现问题,上线切换时间会被拖的很长。

又因为均为查询服务,可以复制线上流量到新的网关,排查哪些服务有异常,在真正服务线上请求之前都解决掉。

image-20230119150806575

2. 流量复制

2.1 tcpcopy 工具简介

tcpcopy 工具涉及三个角色:源 Server、目的 Server、辅助 Serve,两个可执行文件:tcpcopy、intercept。

其原理比较简单:


这里以建立连接的三次握手为例,再将上述原理用动画演示并做详细解释:

tcpcopy-three-time-handshake

通过在源 Server、目的 Server 和辅助 Server 上抓包,也可以清楚地看到这中间的交互过程。

源 Server 上进行抓包,目的地址是源 Server IP 且端口是 31000 的包,会被复制发往目的 Server 的10030端口(中间 30.255.203.2 为虚假IP):

wireshark-cap-from-source-server

目的 Server 上抓包,可以看到虚假地址与其建立连接的过程:

wireshark-cap-from-target-server

使用步骤

根据官方文档,使用 tcpycopy,有三个步骤(2和3顺序不能变,不然 tcpcopy 拉不起来):

  1. 目标 Server上增加路由,将指定的 IP 路由到辅助 Server
  2. 辅助 Server 上启动 intercept,会拉起端口,用于接受 tcpcopy 的连接
  3. 源 Server 上启动 tcpcopy,指定监听-转发规则,与 intercept 建立连接

具体参数说明

tcpcopy 的具体启动方式如下。其中,80 是源机器上的要监听流量的端口,61.135.233.160:8080 是要转发的地址和端口,-s 61.135.233.161-p 28003 分别表示 intercept 的地址和端口,-c 62.135.200.x 表示的是虚假 Clien IP 地址,要改成9.xxx开头的,不然路由不过去。后边的网络路由问题会有详细的说明。

./tcpcopy -x 80-61.135.233.160:8080 -s 61.135.233.161 -p 28001 -c 62.135.200.x 

一个 tcpcopy 进程可以同时监听多个端口,对应转到多个端口 (80->8080, 81->8081):

./tcpcopy -x 80-61.135.233.160:8080 -x 81-61.135.233.160:8081 -s 61.135.233.161 -c 62.135.200.x

一个 intercept 只服务一个 tcpcopy 进程。intercept 因为具体的启动指令,类似于 tcpdump,-i any 表示捕获所有网卡(包括 lo),-F 'tcp and (port 10010 or port 10011)' 表示监听 TCP 端口 10010 和 10011 的两个端口, -p 28001 表示要监听的端口,以供 tcpcopy 建立连接。-d 表示 daemon 模式,-l 用于指定日志文件。

./intercept -i any -F 'tcp and (port 10010 or port 10011)' -p 28001 -d -l t1.log

2.2 网络路由问题

tcpcopy 官方文档上介绍的在目的 Server 使用 route 指令,将发往虚假IP的转发到辅助 Server 的方案,在简单网络环境下是适用的,但是稍微复杂一点的网络环境便会遇到问题。

本小节在会介绍可能遇到的问题,同时也会补充一些相关的网络知识点。

假源地址的选择

tcpcopy 文档中,介绍拉起 tcpcopy 的示例如下:

./tcpcopy -x 80-61.135.233.160:8080 -s 61.135.233.161 -c 62.135.200.x

这个 -c 62.135.200.x 表示复制流量的假的源 IP 网段,我们在实际用的时候,一定要进行修改。原因是我们内网并没有这个网段,当这个以这个网段为源地址的 IP 包在内网进行转发的时候,要么会在中间路由的过程中被 Drop,要么会被 iptables 给 Drop,是无法被目的 Server 收到的

我这里都改成了新网段的 30.255.xxx.xxx 的源地址。

route 设置网络不可达

在目的 Server 上使用 route 指令设置,选择辅助Server IP 地址作为 Gateway 地址,返回“网络不可达”的失败:

# route add -net 30.255.203.0 netmask 255.255.255.0 gw 10.10.224.174
SIOCADDRT: 网络不可达

以下是目的Server的地址信息:

server-eth1-info

原因是目的 Server 和 辅助Server并不在一个子网上,无法直接将 IP包直接转给辅助Server:目的Server 字网网段 9.85.162.0/23,辅助 Server IP 10.10.224.174 明显是不在同一个网段的。

route 设置仍无法转发

即使是两个 IP 在同一网段,通过 route 指令添加路由规则成功,也可能无法达到转发到辅助Server的目的。

使用两个同一网段的两个 IP 进行测试(最后点分数字是 197 和 174):

gateway-route-detail

因为两个节点的连接,不是直接连通的,而是通过中间的交换机/路由器。要将源地址是30开头的 IP 包从 197 发到 174,经过中间路由节点时,路由器会根据自己的路由表进行转发。

这里有一个 ARP 代理的概念的:路由器连接两个机器,在197 想获取 174 以太网地址时,被告知了一个假的(路由器的)以太网地址,这样的话,即使是同一局域网下 197 和 174 的通信也不是直接通信的,而是通过路由器进行的:

arp-info-show

2.3 简化方案

鉴于以上介绍可能发生的问题,可以使用简单方案减少上述问题的发生:将 intercept 进程启动在目标Server上,则目标Server不需要增加路由,其本身就能处理发往虚假地址的 IP 包。这样的部署方案,省掉了辅助 Server 的角色。

Q:为什么 intercept 可以部署在独立机器,也可以部署在目的主机上,而且启动指令还一样?

A:因为 interept 是监听源 IP 为目的主机的地址或源端口为目的主机的端口的 TCP 包,在目的主机或辅助主机上这两种包都是相同的

tcpcopy-三次握手-简单方案

这样的部署方式缺点也非常明显,在目标 Server 上启动 intercept 比只添加一条路有规则更具有侵入性:1. 需要有目的主机的操控权限,2. intercep的部署影响目标 Server 服务性能的评估,3. 事后需要单独的部署和清理。下一小节会介绍实际测试中使用的无侵入的部署方案。

2.4 无侵入方案

新网关提供的 IP 是一个 CLB 地址,无法直接在上边部署 intercept,这里实现上采用无侵入的代理方式进行流量复制转发。进而带来了很多好处:

实际部署

最初部署,使用一台独立机器,在上边部署着:

single-tcpcopy-deployment-in-practice

另外,这里从两个流向进行监控,后边监控部分会对此进行解释。

Nginx 代理

Nginx 可以实现 TCP/UDP 的反向代理,但是需要在构建 Nginx 的时候,带上 --with-stream 选项。详细可参考 《Nginx Doc - TCP and UDP Load Balancing

构建过程:

tar zxf nginx-1.22.1.tar.gz
cd nginx-1.22.1
./configure --prefix=/data/nginx --user=www --group=www --with-http_ssl_module --with-http_flv_module --with-http_stub_status_module --with-http_gzip_static_module --with-pcre --with-stream

配置示例:

stream {
    server {
        listen     12345;
        proxy_pass stream_backend;
    }
    upstream backend_18600 {
        server 10.10.10.12:18600 max_fails=3 fail_timeout=30s;
    }
}

Prometheus 容器

利用 Docker 能够轻松地运行任何进程,不用安装、方便清理。

直接使用 Docker 运行 Prometheus,只需要将 Prometheus 的数据文件,通过 volumn 的方式挂载就可以,即使 Docker 容器重启,数据也会持续保存:

mkdir traffic_copy_prom && cd traffic_copy_prom
mkdir prometheus && chmod 777 prometheus

docker run -d -v /sys/fs/cgroup:/sys/fs/cgroup:ro --network="host" \
-p 19090:9090 -v $PWD/prometheus:/prometheus \
-v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \
prom/prometheus \
--storage.tsdb.retention.time=1y \
--config.file=/etc/prometheus/prometheus.yml

!!! 这里有一点需要注意的, Prometheus 默认存储是 15 天过期,如果要加长存储过期时间,可以使用 --storage.tsdb.retention.time=1y 选项。本来本文详实的监控图片,但阳了一个星期回来一看流量复制初期的图表曲线都没了,:"-(

启动 Prometheus 容器之前,需要在工作目录下创建 prometheus.yml,添加上之前拉起的 Python 脚本对应的暴露服务地址即可:

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
    - targets: ['10.10.224.174:13001']
  - job_name: 'ingress_stat'
    static_configs:
    - targets: ['10.10.224.174:13011']

Grafana 服务

因为我们所处的办公环境,无法直接通过 IP 访问 IDC 的服务,必须要配置对应域名以进行访问。而通过 Koala 或 GDP 等服务开发平台可以快速地创建一个 Grafana 服务并配置对应域名。

docker pull grafana/grafana-enterprise
# 以上获取的镜像 commit 为 c05a39f58366
docker push c05a39f58366 mirrors.tencent.com/jasonzxpan/grafana

在 Koala 上新创建服务,选择这个镜像即可:

choose-custom-image-on-koala

待容器拉起之后,配置后端服务(3000端口),创建南天门网关,即可进行访问。

2.5 全流量复制

流量复制能检测出服务的逻辑是否正常,而全流量的复制则能够检查新服务的负载是否能够满足要求。

没有使用 tcpcopy 自带的流量放大功能。而是在每台旧网关启动一个 tcpcopy,在无侵入代理机上启动一个 intercept 服务该 tcpcopy,而且这里部署了 3 台无侵入代理机。这样部署是基于以下几个考虑:

multiple-tcpcopy-deployment-in-practice

3. 监控统计

3.1 监控指标

开始流量复制之后,需要对复制流量的请求和结果进行统计,以确定新的服务是否都能正常服务。正常服务的标准包括:服务是可以访问的、错误率与原服务相同、访问高峰能够支撑与原来相同的请求量。

根据这些评判标准,这里的监控分为两部分:

前期单网关网关流量视图

grafana-monitor-01

全流量复制单网关流量视图

grafana-monitor-02

某新服务在高峰时部分失败

grafana-monitor-03

L5调整之后部分服务仍有访问

grafana-monitor-04

3.2 网络抓包与统计

因为请求中都带有服务ID,绝大多数返回中会带有服务ID和错误码。因此,无需进行连接级别的监控统计,而是可以直接通过抓取 TCP 包的方式,得到活动的请求和返回信息。

而具体的实现上,是通过 tcpdump 抓取内容 + Python 统计暴露的方式进行:

# 监控发往上游的请求,统计入口信息
tcpdump -i eth1 tcp "dst port 48100 or dst port 48011" -s0  -A | \
python3 expose_metrics_ingress.py rc-new-test 13011 &

# 监控上游返回信息,统计错误码
tcpdump -i eth1 tcp "src port 48100 or src port 48011" -s0  -A | \
python3 expose_metrics_errcode.py rc-new-test 13001 &

指标暴露脚本比较简短,贴在这里供参考:

from prometheus_client import Counter

def main():
    _, gateway_name, port = sys.argv
    t = threading.Thread(target=lambda: start_http_server(int(port)), daemon=True)
    t.start()
    c = Counter('request_total', 'Response Status Counter', ['gw', 'cred_id'])

    for line in sys.stdin:
        m = re.search('"credid"\s*:\s*"([^"]*)"', line)
        if not m:
            continue
        cred_id = m.groups()[0]
        c.labels(gateway_name, cred_id).inc()

    t.join()

if __name__ == "__main__":
    main()

3.3 查询语句与 API 汇总

页面配置变量

这里使用网关(gw),活动ID(cred_id),错误码(errcode) 三个变量用于过滤曲线:

grafana-config-variables

页面配置曲线

-- 按集群+CRED_ID+错误码曲线(服务实际返回状态码)
sum by(errcode, cred_id, gw) (rate(response_status_total{gw=~"$gw", cred_id=~"$cred_id", errcode=~"$errcode"}[$__rate_interval]))

-- 按集群+CRED_ID 访问曲线(入口流量)
sum by(cred_id, gw) (rate(request_total{gw=~"$gw", cred_id=~"$cred_id"}[$__rate_interval]))

-- 按集群的网关错误流量
sum by(cred_id, gw) (300*rate(response_status_total{gw=~"$gw",errcode="-10001"}[$__rate_interval]))

-- 按集群总入口流量
sum by(gw) (300*rate(request_total{gw=~"$gw"}[$__rate_interval]))

报表查询API

按照条件获取标签值的集合(Querying label values):

params = 'match[]={__name__="response_status_total", job="prometheus", gw="%s"}' % gw_
r = requests.get('http://localhost:9090/api/v1/label/cred_id/values', params=params)
output_cred_id = set((r.json()['data']))

普通查询,在某个时间点上 QL 对应的值(Instant queries);如果要查询一段时间,可以使用 GET /api/v1/query_range 接口,带上 startendstep 等参数:

params = f'query={ql}&time={int(time.time())}'
r = requests.get("http://localhost:9090/api/v1/query", params=params)
result = r.json()['data']['result']

某个指标的在一段时间的增长值:

-- 一段时间内,某个指标的增长值
sum without (instance, errcode) (increase(response_status_total{job="prometheus", gw=~"$gw", cred_id=~"$cred_id"}[2h]))

报表内容

监控的目的是检查出哪些服务有异常,统计报表和曲线的侧重点不一样:

这里介绍在流量复制过程中对监控指标统计报表有哪些内容:

4. DRY

“Don’t repeat yourself” (DRY)

4.1 使用脚本生成脚本

上边提到的部署,涉及到多个老网关(每台网关需要部署一个 tcpcopy)、多个无侵入代理(每台代理上有一个Nginx、多个intercept、多个网络捕获监控)、多个新网关。如果手动去启动每个 tcpcopy/intercept、Nginx、网络捕获监控进程,会非常的复杂并难以维护。

这里在唯一一处维护着以下关系,使用 Python 脚本生成 Bash 脚本,保证每个网关上都使用完全相同的脚本&启动指令,每个代理上使用完全相同的脚本启动 intercept、捕获监控。

网关名称 老网关机器IP 老网关端口 代理IP 代理端口 intercept端口 虚假Client IP段 监控端口 新网关CLB 新网关端口
⑧⑨

每个网关上都使用完全相同的脚本&启动指令,脚本内部自动根据网关IP选择启动参数! (重复3遍)

以生成的代理上的脚本为例,先获得本机 IP,再利用 Bash 的 case 语法,匹配到对应的启动指令集。

function start_intercept {
  in_name=`route -n |grep ^[0-9]|awk '{print $NF}'|sort|uniq -c|sort -n -k1 -r|head -1|awk '{print $NF}'`
  ip=`/sbin/ifconfig ${in_name} |grep "inet"|grep -v inet6|awk '{print $2}'|sed 's#addr:##'|head -1`
  ps -ef | grep "./intercept" | grep -v grep | awk '{print $2}' | xargs kill

  case $ip in
    10.10.224.174) # 根据代理IP过滤需要的启动指令
      ./intercept -i any -F 'tcp and (port 10010 or port 10011)' -p 28001 -d -l t1.log # serving gw(9.9.9.193)
      ./intercept -i any -F 'tcp and (port 10020 or port 10021)' -p 28002 -d -l t1.log # serving gw(9.9.9.194)
      # ... 生成更多的intercept启动指令
      ;;
  # ... 生成更多的case记录
  esac
}

case $1 in
  start_intercept|stop_intercept)
    $1;
    ;;
  *)
    echo "invalid action"
    ;;
esac

对应之前提到进程的部署进程,都可以选择其中的几列信息,拼装生成脚本的脚本:

4.2 Docker 镜像大小优化

迁移时,使用的基础镜像和运行镜像都是利用在运行的 Docker container 上运行 bash 构建出的镜像。这样产出的镜像比较大(7G),会影响构建和运行的效率,动辄 5 分钟以上。

这里利用 Docker 多层合并成一层的方法,将镜像大小减小到 2G。详细方式和原理点击 【后台技术】给Docker镜像瘦瘦身 查看。

4.3 其他脚本使用

部署新的无侵入机时,将Nginx 的安装、Docker 拉取和启动、文件夹的创建,都放到脚本中,避免重复工作。

迁移过程中,有使用到脚本做一些信息的快速采集:

机器清理时使用脚本:

4.4 导出/导入 Grafana 配置

容器有重启的风险,因此需要及时地将配置好曲线进行导出,当容器重启时能一键导入便可以恢复。

grafana-config-01 grafana-config-02 grafana-config-03

5. 扩展讨论

TODO:如果请求中包含修改请求,该如何做流量复制?