一、连接迁移
连接迁移不是一个新技术。在LVS中其实已经广泛应用,比如LVS集群中的两个主备负载均衡节点,当主备之间的心跳失效时,备节点成为主节点。此时客户端跟主节点建立的连接怎么处理?在主备之间进行心跳时,主会将连接信息同步给从节点,当主节点宕机时,从可以按照这些连接信息进行连接重建。
在mesh热升级时,为什么需要迁移ServerSocket?
假设不迁移,新的进程依然启动一个ServerSocket,这时有两种情况:
- 启动新的端口
这种情况,新的端口不能直接注册到注册中心,否则流量会翻倍;需要等新的注册完,通知旧的进程注销或者旧的先注销新的再注册,不管怎么样流量不够平稳。
还有一个问题,agent升级时导致端口的维护问题。假设默认port是8000,新的进程用8001,两个端口轮流使用,同一个服务有可能存在两个端口同时存在,运维成本高。
- 复用旧的端口
此时旧的进程一直会有流量,无法迁移完。
采用迁移ServerSocket的方案,可以使得端口不变而且新的连接自动与新进程建立。
二、mesh平滑升级
1. 基本原理
平滑升级不仅要迁移server socket fd,也要迁移socket fd。但是一旦迁移socket fd可能会有点棘手的地方:如果在旧的进程上,这个连接已经读取了部分数据,此时被迁移到了新的进程上,旧的进程需要将这部分存量数据转发给新的进程,新的进程才能将这个连接注册到netty继续读数据;如果旧的进程恰好都是读取的完整数据,不存在存量的读数据,但是有可能这个请求还没有被处理完,此时连接被迁移了,在写响应的时候,旧进程的连接不可用,此时,需要将这个响应数据发送给新的进程,交由新进程进行发送。
首先介绍一下,Agent需要提供2个端口,一个是与业务进程进行交互的端口记为Agent1,简记为A1,一个是对外发布服务accept请求的端口,记为Agent2,简记为A2。业务进程可能作为客户端存在,可能作为接收Agent2转发来的请求。基本结构图如下所示:
热升级需要迁移的内容,其实都是服务端的内容:服务端的server socket、服务端accept的socket、服务端接收的存量请求数据、服务端需要发送的存量响应数据。
Agent1 作为转发请求的server端,仅仅accept C端的连接,由于是连接池,会有多条。
由于转发请求到接收请求的server端,会跟接收请求的server端创建很多连接,但是这些连接无需迁移。因为创建连接的主动权在A1逻辑中。
Agent2 作为接收请求的server端,由于服务于各业务方,会accept A1的很多连接,需要迁移多条。
接收到的请求会被转发给后面的业务S端,这些连接也不用迁移,因为可以自动的被重建。
在迁移过程中,Agent1的迁移和Agent2的迁移是独立的但不是完全独立的。Agent1与业务进程的交互更多,Agent2仅仅是将接收进来的请求转发给业务进程而已。业务进程启动的时候,需要将依赖的服务发送给Agent1,让Agent1进行初始化连接;业务进程等待Agent1完成客户端的初始化之后,向Agent1发送注册服务请求,此时Agent才会创建监听Agent2端口,此时流量才会从Agent2流向业务进程。
新的Agent启动之后,首先进行的A1的迁移,新的Agent从旧的Agent接收到依赖信息和服务注册信息,根据依赖自然会重新创建依赖连接;但是服务注册信息,新的Agent不会直接创建监听Agent2的端口,而是需要进行迁移逻辑。此后A1和A2分别进行迁移各自的ServerSocket、Socket、存量数据。当A1和A2都迁移完之后,新的Agent做好热升级的UDS监听socket,为下次热升级做准备。
2. 具体技术
Netty提供了socket文件描述符的API,可以进行套接字文件描述符的迁移,只是需要配置ServerBootstrap的childOption为:EpollChannelOption.DOMAIN_SOCKET_READ_MODE, DomainSocketReadMode.FILE_DESCRIPTORS。
.childOption(EpollChannelOption.DOMAIN_SOCKET_READ_MODE, DomainSocketReadMode.FILE_DESCRIPTORS)
对于旧进程发送文件描述的Bootstrap没有什么特殊配置。
当文件描述符FileDescriptor被传递过来之后,需要根据FileDescriptor重建ServerSocketChannel或者SocketChannel。这些netty都提供了API:
public EpollServerSocketChannel(int fd) {
// Must call this constructor to ensure this object's local address is configured correctly.
// The local address can only be obtained from a Socket object.
this(new LinuxSocket(fd));
}
由于netty的init和register都是在bind方法中进行的,但是新的进程又不能再次执行bind,因此需要通过反射的方式进行init和register。
ServerSocketChannel serverSocketChannel = NettyManager.buildServerSocketChannel(fd);
ServerBootstrap newServerBootstrap = NettyServerBootstrap.serverBootstrap(new EchoServerHandler(channels));
Method channelInit = ServerBootstrap.class.getDeclaredMethod("init", Channel.class);
channelInit.setAccessible(true);
channelInit.invoke(newServerBootstrap, serverSocketChannel);
EventLoopGroup group = NettyServerBootstrap.getBoss();
ChannelFuture future = group.register(serverSocketChannel).sync();
这样,新的进程就创建了监听器,可以accept新的连接了。旧进程关闭ServerSocket,旧进程不再accept新的连接。
针对旧进程已经创建的连接,需要迁移这些“数据连接”。同样需要根据FileDescriptor重建Socket。
public EpollSocketChannel(int fd) {
super(fd);
config = new EpollSocketChannelConfig(this);
}
由于netty在accept连接的时候,会将handler与channel建立绑定关系。但是现在这些连接都是迁移过来的,需要编码使handler与channel建立绑定关系,并注册netty事件模型。然后旧的进程就可以注销这个连接了。
FileDescriptor fileDescriptor = (FileDescriptor) msg;
SocketChannel socketChannel = NettyManager.buildSocketChannel(fileDescriptor.intValue());
ChannelPipeline channelPipeline = socketChannel.pipeline();
channelPipeline.addLast(new LineBasedFrameDecoder(1024, true, true));
channelPipeline.addLast(new StringDecoder());
channelPipeline.addLast(new StringEncoder());
channelPipeline.addLast(NettyServerBootstrap.getHandler());
NettyServerBootstrap.getWorker().register(socketChannel).sync();
其实迁移过来的连接不能立即注册到netty上,因为旧的进程可能已经读取了部分数据,如果新的进程立即注册的话,数据就会不完整。因此需要先进行数据的迁移,等旧的进程将该连接的数据传送过来之后再注册,继续读取后续的数据。此时有一个问题:旧进程的channel和新进程的channel怎么对应上呢?新的进程收到了数据,怎么知道这个数据是哪个channel过来的,继续读取呢?因此,在上一个socket迁移时,旧进程需要建立旧进程channel和新进程channel的对应关系。当旧的进程发送数据的时候顺便把与之对应的新进程的channel id也发送过去,这样新的进程就能知道在哪个channel上继续读取数据。
还要一个问题:旧的进程怎么获取存量的未读取完整的数据呢?其实,这些数据都在decoder的cumulation中(ByteToMessageDecoder)。所以,直接从这里面获取之后,连同新进程的channel id一起发送给新进程,新进程收到数据之后,根据channel id获取对应的channel,将数据写到decoder的comulation中,然后注册socket,继续进行数据读取。
旧进程:
ChannelHandler channelHandler = channel.pipeline().get("decoder");
// 调用ByteToMessageDecoder的internalBuffer方法
ByteBuf remain = channelHandler.getRemainData();
/**
* Returns the internal cumulative buffer of this decoder. You usually
* do not need to access the internal buffer directly to write a decoder.
* Use it only when you must use it at your own risk.
*/
protected ByteBuf internalBuffer() {
if (cumulation != null) {
return cumulation;
} else {
return Unpooled.EMPTY_BUFFER;
}
}
新进程:
ByteToMessageDecoder decoder = ((ByteToMessageDecoder) channel.pipeline().get("decoder"));
ByteToMessageDecoder clazz = decoder.getClass();
Field cumulation = clazz.getDeclaredField("cumulation");
cumulation.setAccessible(true);
cumulation.set(decoder, remainData);
连接、数据迁移完之后,新的进程需要kill所有旧的进程,并建立好下次“热升级”的domain server socket。
三、demo
最后给出连接迁移的代码demo。详见git:
硬广告
欢迎关注公众号:double6