一个快照,一条流
目标
亲手编写一个读取快照文件并推送 CDS、LDS 的最小 ADS 服务器,并用从该服务器接收全部配置的 Envoy,在流上观察 ACK、NACK、重新连接和热重启。
为什么重要
istiod 与 sidecar 之间发生的就是这件事。但在网格中看不到这场对话,所以遇到“改了配置,只有部分还是旧的行为”这类症状时,不知道该从哪里看起。亲自在自己编写的服务器日志中读取一次 nonce、ACK、NACK、version_info,就能明白 proxy-status 的 SYNCED 和 istiod 的拒绝日志指的是什么。
步骤
- 启动三个上游——
python3 /opt/lab/envoy/upstream.py 8096 ok、… 8097 ok、… 8098 slow。并在/root/envd-ads/snapshot.json中写入版本为"1"的快照:clusters中有pool(8096、8097 两个端点,connect_timeout为 1s)和slow(8098),listeners中有127.0.0.1:10088的edge——路径/marker直接返回正文snapshot=1,/slow发往slow,其余发往pool。资源按 Envoy 的 JSON 形态原样书写。 - 在
/root/envd-ads/ads_server.py中实现envoy.service.discovery.v3.AggregatedDiscoveryService的StreamAggregatedResources,并在127.0.0.1:18000上启动(用/opt/xds/bin/python3运行)。要求有四点——(1) 读取快照文件,把被请求类型(CDS、LDS)的资源全部连同version_info、nonce一起发送;(2) 文件的版本变化时,不等待请求,直接向已打开的流推送新的响应;(3) 对收到的每个请求,向/root/envd-ads/ads.log留下一行 JSON——event(nonce 为空时为request,有 error_detail 时为nack,否则为ack)、stream、node、type、version_info、response_nonce、error;(4) 发送响应时,也留下event: sent以及版本和 nonce。 - 在
/root/envd-ads/bootstrap.yaml中写入节点 idenvd-ads-1(cluster 为envd-ads)、管理端口9988,dynamic_resources的ads_config(gRPC、V3,集群xds_cluster)以及cds_config、lds_config写作ads: {},静态资源只放一个用于连接控制平面的xds_cluster(127.0.0.1:18000,HTTP/2)。用envoy -c /root/envd-ads/bootstrap.yaml --base-id 50 --restart-epoch 0 --concurrency 1 --drain-time-s 5 --parent-shutdown-time-s 10启动。 - 把快照改为版本
"2"——从pool中去掉 8097,并把/marker的正文改为snapshot=2。文件写成新文件后用mv替换。等 Envoy 接受之后,在/root/envd-ads/04-push.txt中写入marker=(/marker的响应正文)和endpoints=(/clusters中剩余的 pool 端点数)两行。 - 把快照改为版本
"3",同时把pool的connect_timeout设为"0s",把/marker的正文设为snapshot=3。然后在/root/envd-ads/05-nack.txt中写入四行——从 ads.log 中找到的 CDS NACK 的version_info写为nack_version_info=,拒绝原因中以ConnectTimeout开头的片段写为reason=,以及此刻 Envoy 的cluster_manager.cds.version_text、listener_manager.lds.version_text分别写为cds_version=、lds_version=。 - 停止 ADS 服务器,在
/root/envd-ads/06-down.txt中写入traffic=(/请求的状态码)和connected=(control_plane.connected_state)两行。然后把快照修改为版本"4"(在 3 的基础上把 connect_timeout 改回1s,/marker为snapshot=4),重新启动服务器,等到 Envoy 重新连上并接受版本 4。把重新连上的流的第一个 CDS 请求所携带的version_info,以resume_cds_version=追加到同一个文件。 - 把对
/slow(耗时 3 秒)的一个请求放到后台发送,让结果保存到/root/envd-ads/inflight.txt,在这个请求运行期间,用相同的 bootstrap 启动 epoch 1 的 Envoy(--base-id 50 --restart-epoch 1 --concurrency 1 --drain-time-s 5 --parent-shutdown-time-s 10)。在新进程变为 LIVE、旧进程退出之后,在/root/envd-ads/07-restart.txt中写入epoch=(管理端口/server_info中的 restart_epoch)、inflight=(后台请求的状态码)、new_stream_version=(新进程打开的流的第一个 CDS 请求的 version_info——为空则写empty)三行。 - 在
/root/envd-ads/08-report.md中写入五行——first_request_version=(Envoy 最初发送的 CDS 请求的 version_info,为空则写empty)、nack_version_info=、resume_cds_version=(第 5、6 步的记录)、epoch_after_restart=、inflight_code=(第 7 步的记录)——并在下面至少写四行学到的内容。
参考
- ADS 服务器用
/opt/xds/bin/python3运行(装有 grpcio 和 Envoy API 的 protobuf 的虚拟环境)。 - 快照请写入新文件后用
mv替换。如果服务器读到只写了一半的文件,会因 JSON 错误而跳过(服务器日志中会留下 snapshot_error)。 - Envoy 在第 3 步以
--restart-epoch 0启动,在第 7 步之前不要重新启动。重新启动的话,第 7 步的 epoch 就会错位。要关闭它,使用curl -X POST localhost:9988/quitquitquit。 - ads.log 是每行一条 JSON。请像
jq -c 'select(.event=="nack")' ads.log这样筛选查看。 - 常见错误——
pkill -f ads_server.py。包含该字符串的 shell 也会一起被杀掉。请写成'[a]ds_server.py'。
把要推送的配置写成快照
启动三个上游——python3 /opt/lab/envoy/upstream.py 8096 ok、… 8097 ok、… 8098 slow。并在 /root/envd-ads/snapshot.json 中写入版本为 "1" 的快照:clusters 中有 pool(8096、8097 两个端点,connect_timeout 为 1s)和 slow(8098),listeners 中有 127.0.0.1:10088 的 edge——路径 /marker 直接返回正文 snapshot=1,/slow 发往 slow,其余发往 pool。资源按 Envoy 的 JSON 形态原样书写。
控制平面的工作归根结底就是把“此刻这个代理应该拥有的全部资源”用一个版本绑在一起。这个整体称为快照。资源只要按 Cluster、Listener protobuf 的 JSON 表示原样写出即可,监听器内部的 typed_config 需要 @type。评分器会用 xds-protos 把这份 JSON 真正读成 protobuf——哪怕一个字段名称有误,也会卡在这里。
编写最小的 ADS 服务器
在 /root/envd-ads/ads_server.py 中实现 envoy.service.discovery.v3.AggregatedDiscoveryService 的 StreamAggregatedResources,并在 127.0.0.1:18000 上启动(用 /opt/xds/bin/python3 运行)。要求有四点——(1) 读取快照文件,把被请求类型(CDS、LDS)的资源全部连同 version_info、nonce 一起发送;(2) 文件的版本变化时,不等待请求,直接向已打开的流推送新的响应;(3) 对收到的每个请求,向 /root/envd-ads/ads.log 留下一行 JSON——event(nonce 为空时为 request,有 error_detail 时为 nack,否则为 ack)、stream、node、type、version_info、response_nonce、error;(4) 发送响应时,也留下 event: sent 以及版本和 nonce。
gRPC 的双向流在 Python 中是“接收请求迭代器并 yield 响应的函数”。但在等待请求期间也必须推送快照的变化,所以比较方便的做法是:由另一个线程读取请求并放入队列,主循环短暂等待队列,同时检查快照。资源要用 Pack 放入 google.protobuf.any_pb2.Any,把 JSON 转为 protobuf 时使用 json_format.ParseDict(必须先 import 监听器内部的 HCM、router 类型,才能解析)。评分器会直接向这台服务器打开一条流,请求 CDS。
不设静态配置,从控制平面接收一切
在 /root/envd-ads/bootstrap.yaml 中写入节点 id envd-ads-1(cluster 为 envd-ads)、管理端口 9988,dynamic_resources 的 ads_config(gRPC、V3,集群 xds_cluster)以及 cds_config、lds_config 写作 ads: {},静态资源只放一个用于连接控制平面的 xds_cluster(127.0.0.1:18000,HTTP/2)。用 envoy -c /root/envd-ads/bootstrap.yaml --base-id 50 --restart-epoch 0 --concurrency 1 --drain-time-s 5 --parent-shutdown-time-s 10 启动。
bootstrap 中只剩下“找到控制平面的路”。监听器和业务集群全部通过 ADS 接收。ads: {} 的意思是“不要单独订阅 CDS、LDS,而是用一条 ADS 流来接收”,所以两种类型会在同一条流中按顺序收发。--base-id 和 --restart-epoch 是为第 7 步的热重启准备的,从现在起就加上。是否连接成功,通过统计 control_plane.connected_state 和 curl localhost:9988/config_dump 的动态部分来看。
不重启就去掉端点
把快照改为版本 "2"——从 pool 中去掉 8097,并把 /marker 的正文改为 snapshot=2。文件写成新文件后用 mv 替换。等 Envoy 接受之后,在 /root/envd-ads/04-push.txt 中写入 marker=(/marker 的响应正文)和 endpoints=(/clusters 中剩余的 pool 端点数)两行。
服务器会自己察觉文件发生了变化,并向已打开的流发送新的响应。Envoy 是否接受,可以通过 ads.log 中的 ack(version_info 为 2)和统计 cluster_manager.cds.version_text 来确认。为了不让服务器读到只写了一半的文件,请写成新文件后再移动过去。
被 Envoy 拒绝的配置会怎样返回
把快照改为版本 "3",同时把 pool 的 connect_timeout 设为 "0s",把 /marker 的正文设为 snapshot=3。然后在 /root/envd-ads/05-nack.txt 中写入四行——从 ads.log 中找到的 CDS NACK 的 version_info 写为 nack_version_info=,拒绝原因中以 ConnectTimeout 开头的片段写为 reason=,以及此刻 Envoy 的 cluster_manager.cds.version_text、listener_manager.lds.version_text 分别写为 cds_version=、lds_version=。
0 秒的超时对 protobuf 来说是正常的,所以能通过服务器的 JSON 转换。卡住它的是 Envoy 的校验规则,Envoy 不会应用这个响应,而是用相同的 nonce 再次发出请求,并在 error_detail 中带上原因返回。请看看那时的 version_info 是什么。而且 CDS 和 LDS 即使来自同一个快照,也是分别应用的——监听器没有问题,所以会被接受。也请通过 /marker 确认哪些内容发生了变化。
控制平面挂掉,又回来
停止 ADS 服务器,在 /root/envd-ads/06-down.txt 中写入 traffic=(/ 请求的状态码)和 connected=(control_plane.connected_state)两行。然后把快照修改为版本 "4"(在 3 的基础上把 connect_timeout 改回 1s,/marker 为 snapshot=4),重新启动服务器,等到 Envoy 重新连上并接受版本 4。把重新连上的流的第一个 CDS 请求所携带的 version_info,以 resume_cds_version= 追加到同一个文件。
已经收到配置的 Envoy,没有控制平面也能用那份配置继续运行——停止的只是接收新配置这件事。重新连上时,Envoy 不会空手开始,而是在第一个请求中携带每种类型最后接受的版本。重新启动服务器后,日志中会新打印 server_started,看在它之后的第一个 Cluster request 即可。pkill -f 的模式请像 '[a]ds_server.py' 这样把第一个字符用方括号括起来。
连代理也不中断,换成新进程
把对 /slow(耗时 3 秒)的一个请求放到后台发送,让结果保存到 /root/envd-ads/inflight.txt,在这个请求运行期间,用相同的 bootstrap 启动 epoch 1 的 Envoy(--base-id 50 --restart-epoch 1 --concurrency 1 --drain-time-s 5 --parent-shutdown-time-s 10)。在新进程变为 LIVE、旧进程退出之后,在 /root/envd-ads/07-restart.txt 中写入 epoch=(管理端口 /server_info 中的 restart_epoch)、inflight=(后台请求的状态码)、new_stream_version=(新进程打开的流的第一个 CDS 请求的 version_info——为空则写 empty)三行。
热重启是新进程从旧进程那里接管监听器套接字的方式。所以端口一刻都不会关闭,旧进程会在 drain 时间内把正在处理的请求做完,然后退出。两个进程通过 --base-id 互相寻找,所以必须相同,epoch 则逐个加一。新进程不会继承旧进程的 xDS 状态——它会重新连接控制平面,从头开始接收。旧进程是否已退出,用 pgrep -af 'restart-epoch 0' 查看。
整理在流上看到的内容
在 /root/envd-ads/08-report.md 中写入五行——first_request_version=(Envoy 最初发送的 CDS 请求的 version_info,为空则写 empty)、nack_version_info=、resume_cds_version=(第 5、6 步的记录)、epoch_after_restart=、inflight_code=(第 7 步的记录)——并在下面至少写四行学到的内容。
值请取自 ads.log 和证据文件。说明行中请用自己的话写下“NACK 的 version_info 为什么不是被拒绝的版本”,以及“控制平面故障和代理重启各自会失去什么”。