彻底拆解 rancher:隧道模块

·  阅读 823

首先明确一下定义,一般来说隧道指将后端服务通过代理服务器暴露为共有服务时,从网关到后端服务的通信连接称为隧道。

在单集群内,隧道可以通过 ingress,比如 nginx ingress controller 建立,这是因为在同一个集群内,网关可以直接访问后端服务。

在多集群管理的语境下,隧道的难点主要在于网关往往不可直接访问后端服务。下文的所有隧道均基于这一背景。

通信隧道简介

为什么需要隧道

在未来越来越注重网络安全的情况下,各个集群与外部网络的链接将严格首先,出入流量会被 ingress/egress 网关严格把控。各个 k8s 集群作为一个 backend cluster,其 APIServer 并不直接服务用户,故基于 k8s 的 PaaS 平台决不可直接将 k8s 的 APIServer 暴露,否则会带来令人头疼的权限问题和安全隐患。

同时 PaaS 平台的 API gateway 往往和各个集群不在同一个子网内,甚至不在同一个机房内。此时网关直接访问服务受限,通信时,只能通过由 agent 主动发起的连接进行。此时该如何建立通信隧道?

隧道的工作原理

上述隧道难以在于:假设有一个 agent 部署在后端集群,若只能在后端集群主动发起连接,整个后端集群如何同时充当服务端?

注:这里没有指明客户端,即不一定需要 agent 同时充当服务端,agent 可将请求转发到 k8s APIServer 即可

答案为 agent 支持双工。双工指通信设备既是发送器,也是接收器。在 IT 中指既可发送请求;又可接受请求。websocket 是一个全双工协议,一端可在接受消息的同时发送消息。相应的 http 1.1 是半双工协议,开启长连接之后客户端可发送多个请求,同时接收服务端回复(但 http 1.1 不支持 server 主动向 client 发起请求)。

双工隧道的本质在于 TCP 连接是全双工的。网络是分层的,无论是 http 还是 websocket 还是其他的协议,均基于传输层的连接 (TCP/UDP) 进行协议封装。不管 agent 和网关建立什么应用层连接,只要复用了底层的 TCP 连接,就可以建立 agent 到网关的双工连接。

考虑这样的场景:agent 主动发起连接,与 server 建立一个 支持双工通信的链接,网关访问 k8s 集群时,请求由 agent 进行转发。这样就实现了我们想要的隧道。

rancher 正是这样做的。agent 发起 websocket 连接,同时作为 k8s APIServer 的代理提供服务。这样网关就可以直接访问 k8s APIServer 了。访问过程中,网关的 http 请求通过 websocket 发送到 agent,agent 收到后再向 APIServer 发送 http 请求。response 的路径是相同的,不再赘述。

业界方案

看过了 rancher 的方案,再看一下业界。有若干种可行的方式,但所有的方式都可以归纳为建立通道,复用连接

  • ngrokexpose a local server behind a NAT or firewall to the internet。目前 v1 已经不再维护,且官网明确表示不要生产环境用 v1。v2 不开源。
  • go-http-tunnel:基于 http2 带来的高性能是这个项目的亮点。但是 GPL-3.0 协议无法商用;且 exec 的时候要用 hijack 技术,但 golang 的源码中表示:The default ResponseWriter for HTTP/1.x connections support Hijacker, but HTTP/2 connections intentionally do not.
  • chisel:利用 ssh 来复用 tcp 链接。但 ssh 端口必然会受到最严格的管控,且数据面的认证会带来新的难题(如引入 keberos 等组件带来的额外复杂度)。
  • remotedialer:利用 http 连接可升级为 websocket 连接的特性,复用底层 tcp 连接建立隧道。HTTP in TCP in Websockets in HTTP in TCP, Tunnel all the things! 同时 websocket 能支持 webshell,且认证依然收缩在控制面。
  • apiserver-network-proxy:基于 grpc 实现,目前完全基于 k8s 的架构,通过 apiserver 的 proxy 能力暴露服务。暂时处于 alpha 阶段。
  • frp:项目庞大,支持 http, http2, websocket 等多种协议,同时有 UI 界面。

rancher remotedialer

源码在 rancher/remotedialer,整个包没有一个注释,作者对大家说:

Refer to server/ and client/ how to use. Or don't.... This framework can hurt your head trying to conceptualize.

所以没什么捷径可走,啃源码吧。

代码结构

  • client: 客户端的样例,稍后介绍
  • metrics: 普罗米修斯相关代码
  • server: 服务端的样例,稍后介绍
  • client_dialer.go: 真正完成 proxy 的模块
  • client.go: 客户端的主要逻辑,发起 ws 请求,并监听该连接
  • connection.go: 对 buffer 和 session 的封装,同时是对 net.Conn 的实现
  • dialer.go: 暴露一个 dialer,可直接用于 http transport 中,定义很微妙,需要深入理解
  • message.go: 定义了若干消息类型,对每种消息类型做不同的处理。最典型的是 connect 消息触发了对目标地址的连接
  • peer.go: 管理建立好连接的其他节点的信息
  • readbuffer.go: 核心结构体,是整个隧道的桥梁
  • server.go: 服务端维护 authorizer 与 session manager。前者负责认证(通过自定义的方式),后者管理所有的 session。server 收到 ws 连接后,开始处理连接中携带的 payload
  • session_manager.go: 管理多个 session
  • session.go: session 维护了 websocket 连接,并负责生成 Dialer 供复用 TCP 连接,使用该 Dialer 可以将流量转发至对端,由对端执行真正的指令
  • types.go: 定义一些常量
  • wsconn.go: 操作封装好的 websocket 连接

样例讲解

在 client 端创建一个 http 服务,监听 localhost:8081。server 端监听 8080 端口。client 端发起 ws 连接。

现在,在 server 所在环境中,执行 curl http://localhost:8080/foo/http/localhost:8081/healthz,可以成功访问到 client 环境中的 web server。

remotedialer 源码详解

这个库的代码量不大,但用了大量 tricky 的处理手法。故这里主要说的其实是怎么阅读这份代码,以及梳理里面的关键流程。

梳理过程将从 server 和 client 两种视角同步进行,以连接的建立、请求的转发为引子,拆解这个子模块的作用。这个过程将涉及视角的切换,请读者务必参照着源码进行阅读和分析。

server 视角

从这里开始

建立服务端

server 端的入口是 remotedialer.New(),它生成一个 server 实例,而 server 有一个 ServeHTTP 方法,来处理 client 端的连接请求,故通过 router.Handle("/connect", handler),加上 client 发来的请求,就可以建立这个隧道。

虽然隧道建立起来了,但我们仍然需要另一个 handler 来对流量进行转发,样例中的另一个 handler,它要做的事情就是根据请求中携带的 client ID,找到 server 中对应的 dialer,封装出一个 http 客户端,执行用户想要进行的操作。

这里要关注两个问题:

  1. handler 到底干了什么?
  2. dialer 到底是什么?

先说第一个问题,看 ServeHTTP 方法,它首先将 http 协议转换为 websocket 协议,之后引入了 session 这个概念,管理这个 websocket 连接。最后通过 session.Serve 来处理 client 端发来的请求。

第二个问题,dialer 是 golang 标准库中的 net.Conn 的实现。这里必须注意的是,标准库中,net.Conn 往往指代一个四层连接,比如 TCP/UDP 等。但这里的 dialer 并不是直接复用 websocket 的 TCP 连接

接收 client 连接请求

通信的开端在于 client 要首先向 server 发起握手请求。client 发起的是一个 websocket 的请求,很标准也很简单,读者如果对 websocket 感兴趣,可参考网上资料。

dialer 的关键代码在 *Server.Dialer 处,通过这个函数,获取一个 dialer。先看 dialer 的定义:

// core concept: Dialer is an implementation of golang 'net.Conn'.
type Dialer func(ctx context.Context, network, address string) (net.Conn, error)
复制代码

golang 中的 net.Conn 往往代表了一个四层的连接,比如 TCP 连接,而 TCP 连接是全双工的,一个 net.Conn 的实现也是全双工的,这就建立了 client 和 server 的双工通信隧道。

这个 dialer 是从 sessions.getDialer 中拿到的。关键代码为

	// if session exists, return
	sessions := sm.clients[clientKey]
	if len(sessions) > 0 {
		return toDialer(sessions[0], ""), nil
	}
复制代码

第一行可以先不看,在 client 发起 connect 请求时,session 被生成,并记录在 clients 中,这部分代码比较简单,读者可以自行研究。重要的函数是 toDialer,它将 session 转换为一个 dialer。从函数调用一路看进去,会看到 s.serverConnect(deadline, proto, address) 这个函数。它干了两件事情,第一件事是建立了一个 connection 实例,它是转发功能的核心组件,稍后再说。第二件事是通过 websocket 连接,向客户端发送一条 CONNECT 消息。这时候我们要调到客户端视角,请阅读 client 视角的内容,不然可能会对整体理解造成困难

第二次视角切换:CONNECT 请求之后

如果读者看了 client 侧的处理,应明白此时 client 本地到目标地址的四层连接已经建立。client 结束对 CONNECT 请求的处理,此时 server 应进行后续 DATA 请求的发送。

但是初步一看代码,并没有明确的向 session 中写入 DATA 消息的代码。那么请求是如何被转发到 websocket 中的呢?

答案在 dialer 上。上面已经说过按照定义可以接受入参,返回一个 net.Conn,看下面的代码(忽略一些不重要的代码):

func (s *Session) serverConnect(deadline time.Time, proto, address string) (net.Conn, error) {
	conn := newConnection(connID, s, proto, address)
	_, err := s.writeMessage(deadline, newConnect(connID, proto, address))
	return conn, err
}
复制代码

这个 net.Conn 实际上是一个 connection 实例。为了说明 connection 是如何工作的,先看一下 net.Conn 的接口定义(只节选最关键的方法):

// Conn is a generic stream-oriented network connection.
//
// Multiple goroutines may invoke methods on a Conn simultaneously.
type Conn interface {
	// Read reads data from the connection.
	// Read can be made to time out and return an Error with Timeout() == true
	// after a fixed time limit; see SetDeadline and SetReadDeadline.
	Read(b []byte) (n int, err error)

	// Write writes data to the connection.
	// Write can be made to time out and return an Error with Timeout() == true
	// after a fixed time limit; see SetDeadline and SetWriteDeadline.
	Write(b []byte) (n int, err error)
}
复制代码

最关键的方法:

  • Read:读对象中读取内容,拷贝到 buffer 中
  • Write:从 buffer 中读取内容,写入到对象中

下面看一下 connection 实现的 net.Conn 有什么神奇的魔力。总结一下 connection 实例对这四个方法的实现:

  • Read:从 buffer 中读取内容,放入到 readbuffer 中
  • Write:将 buffer 中的内容封装为 DATA 消息,写入 session 中的 websocket 中

再结合 dialer 返回之后,封装了一个 http client,代码如下:

	dialer := server.Dialer(clientKey)
	client = &http.Client{
		Transport: &http.Transport{
			DialContext: dialer,
		},
	}
	resp, err := client.Get(url)
复制代码

答案就非常清晰了,在 server 发送请求的时候,调用了 connection 的 Write 方法,向 websocket 发送了一条 DATA 消息。现在,继续把视角切换到 client 侧。

第四次视角切换:收到 response

回忆一下在 serveHTTP 中,session 建立之后开始 serveMessage,与 client 侧类似,接收到 DATA 请求后,调用 conn.OnData。这部分的逻辑在 client 视角中已经全部详细解释过了,这里不再赘述。

最后的效果是通过 websocket 传来的 http response 被缓存到 connection 的 readbuffer 中。

上面已经介绍过:connection 是一个 net.Conn 的实现,在 http client 中,会从这个 net.Conn 中读取数据,实际就是从 readbuffer 中读取数据,这就实现了 response 从 readbuffer 到用户程序的传递。

至此,一个 http 请求正式完成。

总结:server 端的双向通道

用户程序到 websocket 的双向通道通过 dialer 完成,dialer 将生成一个 net.Conn 实例,而它实际是 connection 实例,该实例完成了这条双向通道的搭建。

client 视角

client 的行为比较简单。它首先向 server 发起一条 websocket 的连接请求,等到连接建立之后,调用用户注册的回调函数 onConnect,进行一些自定义的逻辑处理。

之后建立一个 session 并作为'服务端'开始处理对端发来的请求。建立 session 的代码在 NewClientSession 里,并不难理解,主要我们要看 session.Serve(ctx) 其中的 s.serveMessage() 怎么处理服务端的请求的。

第一次视角切换:收到服务端 CONNECT 请求

进入 s.clientConnect(ctx, message) 流程,它新建一个 connection 实例,之后进入 clientDial(ctx, s.dialer, conn, message)。在流程中关注

netConn, err = dialer(ctx, message.proto, message.address)
复制代码

message 中已经携带了我们想要访问的地址,所以这里 netConn 已经建立了和目标地址的四层连接。现在我们要进入整个 remotedialer 包最核心的函数:pipe

pipe 函数的输入参数是 client *connection, server net.Conn,整个流程可以总结为两个函数

go io.Copy(server, client)
io.Copy(client, server)
复制代码

要理解这个流程,首先要理解 io.Copy 干了什么,以及 connection 实例到底是什么?

connection 实例是什么

看 connection 的定义发现,它最核心的内容是包含了 bufferSession。在这里结合上面的内容梳理一下:

  • netConn:client 端本地和 server 真正想访问的地址的四层连接
  • buffer:封装了一个 bytes.Buffer 实例,作用后面说
  • Session:维护了 client 和 server 的 websocket 连接

那到了这里是不是可以有个猜想:buffer 作为缓冲区,实际上是 websocket 连接和这个四层连接的桥梁?那么我们接下来研究一下数据怎么从这个 netConn 被转发到 server 端的。

io.Copy 干了什么

首先看注释:

Copy copies from src to dst until either EOF is reached on src or an error occurs.

结合上面的 pipe,不难想到做的事情就是将数据从 connectionnetConn 中相互传递。

接着看一下 io.Copy 的流程(省略了很多错误处理):

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
	if wt, ok := src.(WriterTo); ok {
		return wt.WriteTo(dst)
	}
	// Similarly, if the writer has a ReadFrom method, use it to do the copy.
	if rt, ok := dst.(ReaderFrom); ok {
		return rt.ReadFrom(src)
	}
	for {
		nr, er := src.Read(buf)
		if nr > 0 {
			nw, ew := dst.Write(buf[0:nr])
			if nw > 0 {
				written += int64(nw)
			}
		}
	}
复制代码

涉及到了四个函数:WriteTo, ReadFrom, Read, Write,提一下 Read 中的数组并不是从数组中读取,而是读取后写入数组,Write 中的数组也不是写入数组,而是将数组内容写入实例。netConn 作为标准库实现,这四个函数就不展开了,大致我们也能理解大致含义,无非是网络连接的处理,将数据从内核 buffer 中读取或写入,交由内核处理。整理一下 connection 的这四个函数:

  • WriteTo:未实现
  • ReadFrom:未实现
  • Read:调用 c.buffer.Read(b),内部调用 r.buf.Read(b),将内容从 connection 的 buffer 中读取并复制到 b
  • Write:调用 c.session.writeMessage(c.writeDeadline, msg),将内容封装为一条 message,通过 session 中的 websocket 连接发送给对端

现在就可以理解 pipe 到底在干什么了,包括两条链路

  1. connection -> netConn:调用 connection 的 Read 方法,将 buffer 中的内容写入 netConn 中,其实就是执行 request
  2. netConn -> connection:调用 connection 的 Write 方法,将 netConn 中的内容写入到 websocket 中,其实就是将 response 返回给 server

现在大致上这条隧道我们已经整理的七七八八了,但还缺最后一环:**数据怎么从 websocket 传递到 connection 的 readbuffer 中?**在解决这个问题前,我们回到 client 端的处理,由于 client 端在执行完 clientDial 后就返回了,server connect 的流程到底结束。现在将视角切换回服务端。

第三次视角切换:client 收到 DATA 请求

和收到 CONNECT 请求类似,一路跳转到 serveMessage 函数中,找到 conn.OnData(message) 函数,它就是上面所说的 client 侧隧道的最后一块拼图。

connection readbuffer 和 websocket 的通道

OnData 函数比较简单:

// OnData copy payload to buffer in connection.
// Note message carries reader from websocket
func (c *connection) OnData(m *message) error {
	return c.buffer.Offer(m.body)
}
复制代码

进入 Offer 函数中,忽略锁和循环,核心代码依然是

n, err := io.Copy(&r.buf, reader)
复制代码

这里的 reader 实际上是 bufio.Reader,封装在 message 中,而 message 的内容来源是 websocketr.bufbytes.Buffer

调用 readerRead,调用 r.bufWrite;意味着从 websocket 中读取消息,写入 connection 的 readbuffer 中。

总结:client 侧的双向通道

结合上面所说,整理一下 client 侧的双向通道:

  • client 本地和目标地址的四层连接:server 发送 CONNECT 消息后,client 与目标地址建立 TCP 连接
  • connection 和 websocket:通过 connection 的 readbuffer 双向转发
  • connection 和 TCP 连接:通过 pipe 进行双向转发

client 和 server 侧的双向通道:

  • 复用 websocket 的全双工通信能力
返回 response

根据上面的双向通道,client 侧的 http server 接受到请求,发送 response,这条 response 被一路通过 websocket 转发到 server 端。至此,client 侧的逻辑已经完全清晰。

现在剩最后一个问题:server 端的客户端如何接收到这条请求?现在,把视角切换到 server 端。

limitations

负载均衡

从上面的分析中,我们已经知道了一个请求怎么从网关打到后端的 k8s 服务。网关通常有负载均衡的能力,remotedialer 是如何支持隧道的 HA 和负载均衡的呢?

这部分比较简单,可参考 session 的 peers 子模块。一个 server 端可以由多个 peer 组成,每当一个 client 向某个实例注册一个连接时,所有实例都会与这个 client 建立连接。remotedialer 通过 peersessionListener 实现负载均衡机制的支持

注意 remotedialer 本身不支持负载均衡,而是通过 peer 机制,提供了支持。负载均衡由 remotedialer 的调用方实现。

集群管理

虽然 session manager 对 client 的连接做了简单的管理,但只管理了 client 的身份和认证。

集群管理会涵盖更广的范畴,比如集群的认证。网关达到 k8s 的流量怎么通过 k8s 的认证?网关如何知晓 k8s 的 API endpoint?这些问题并不在 remotedialer 的范畴中,必须由上层调用者管理。

晦涩的机制

整个包居然没有一行注释!只有简单的一句:

HTTP in TCP in Websockets in HTTP in TCP, Tunnel all the things!

在隧道机制上,最核心的在于将 connection(也就是对 websocket 和 buffer)的封装,作为一个 net.Conn 的实现。从网络协议上来说,把一个应用层连接,封装为一个传输层连接。

对 golang net 包不熟悉的人很难理解整个工作流程。

总结

本小节梳理了多集群管理中的隧道模块,并详细的拆解了 rancher 的隧道模块的实现思路。

在下文中,我们将梳理 rancher 如何利用 remotedialer,进行集群的注册和管理。

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改