背景
kubernetes 是当前云原生的底层基础,管理容器化应用。 一般来说,一个服务的生命周期,从裸的部署来说,需要经过 开发代码 - docker 出镜像 - push 到 registry - 修改yaml 文件 - kubectl apply 提交等步骤。 如果需要看日志,则需要 kubectl exec 登录到容器去查看 or kubectl logs 查看。
如果修改了代码,又要经过如此冗长的过程,十分费事费力。
开发阶段本就是会经常需要代码变更,如果能有一套方案,解决从集群流量路由到本地服务,从本地直连集群下游服务,那就基本解决了上下游联调端到端测试验证问题。
于是,telepresence 项目诞生了。
通俗一点说,telepresence 解决的是多次走这个循环的问题,当你在代码频繁修改阶段,想做端到端的测试,可以不需要走这冗长的流程,可以直接将集群流量导到本地测试,本地也可以直连集群下游。
解决方案
telepresence 是 CNCF 的开源项目,解决在本地 debug kubernetes 服务。非常方便的把本地网络和集群网络打通。
功能
- outbound:可以在本地 curl 集群里的service
- inbound:劫持集群里的流量,转发到本地服务
- 环境变量:可以将集群被劫持服务的环境变量,导到本地,支持文件导出
- 卷挂载:mount 远端的存储卷
组件
- telepresence 本地的命令行工具
- telepresence daemon,与命令行工具是同一个 binary,运行在后台
- traffic-manager,权限很高,debug 服务的时候,作为集群服务的总入口
- traffic-agent,与 traffic-manager 是同一个 binary,与被劫持container 部署在一个 pod
使用
安装
- macOS minikube
- kubectl
- GitHub 拉取 v2.1.0 版本自己编译 或者 sudo curl -fL https://app.getambassador.io/download/tel2/darwin/amd64/latest/telepresence -o /usr/local/bin/telepresence
- kubectl apply traffic-manager 代理 pod
- 写好待测服务,这里找了一组服务,包含 handler ,service,datastore 3 个服务,分别为不同的 POD,都部署在 kubernetes 集群,service 服务我们期望通过流量劫持到本地来测试
测试,本地访问远端 kubernetes 集群服务,远端 kubernetes 服务路由到本地服务
这里提供了 3 个服务来做一个例子,服务的架构是这样的:
当前没有用 telepresence 劫持service流量的时候,在本机通过 ingress访问该网址,呈现的是 cloud。
开始劫持流量,将远端 dataprocessing 服务流量转发到本地。
- telepresence connect,本地运行,与远端的 traffic-manager 建立连接 。
- telepresence intercept dataprocessingservice —port 3000 , 表示把远端dataprocessing 服务的 3000 端口流量劫持到本地,下图表示劫持成功了。
- npm install && npm start ,在本地启动 dataprocessing 服务,是一个 nodejs 写的服务,代码非常简单,就是几个 API。
此时,流量的走向是 mac curl handler URL -> kubernetes handler service-> kubernetes dataprocessing(traffic-agent) -> mac local dataprocessing -> kubernetes datastore.
如何我新增一条流量劫持,我希望 datastore 也在本地启动,我用 docker run 了 datastore,监听在一个端口上。 telepresence intercept verylargedatastore —port 8080。 datastore 返回的数据我在本地修改:
public Long getTotalRecordCount() {
System.out.println("getTotalRecordCount is called by dataprocessing");
return 123l;
}
继续在浏览器访问 http://verylargejavaservice.default:8080/
原理、核心源码
协议
- traffic-manager 8022, sshd 监听的端口,做内网穿透反向代理
- traffic-manager 8081,控制层数据,grpc 协议,telepresence daemon 与 traffic-agent 的控制层数据都走 grpc 交互
- traffic-agent 默认监听 9900 的 tcp 端口,做 tcp proxy,请求透明转发到 traffic-manager 然后转发到 Mac 本地
- telepresence mac 1080 端口,socks5 协议,做本地与远端服务建立 tcp 连接的 proxy,这里为什么需要一个 socks5,其实是为了获取一个 tcp proxy,socks5 proxy 自动代理到 ssh 的 tunnel,这块是 ssh 协议的实现,有兴趣的可以去看看
- telepresence mac proxy 监听了一个随机端口,每次运行大概率不一样,不重启一直占着该端口,操作系统根据路由表 redirect 到该端口,proxy 拿到连接通过 socks5 协议与 kubernetes 远端服务建立 tcp 连接,之后把 2 个 connection join 作为一个 pipe tunnel
- telepresence mac dns proxy server,端口随机,和/etc/resolver/xxxx 文件里写的的端口一致
- 本地 mac 运行服务的port, ssh 反向代理到远端 traffic-manager,远端服务直接通过 traffic-manager 的一个 port 即可访问 mac 服务
DNS
telepresence 在本地启动了一个 dns server,通过在 /etc/resolver 里注入了一个文件,让域名解析的时候路由到它的 dns server 里去。 该 dns server 因为是一个后台常驻进程,且与kubernetes 集群的 traffic-manager 保持了通信,故能拿到集群的域名信息。 当需要解析集群域名的时候能立刻解析出 ip。
感兴趣的可以修改一下这个文件,发现在本地无法访问集群里的服务了,基本上可以证明是该文件的作用,抓个包也可以验证。
Mac 路由表
解析了集群的 ip 之后,如何访问。 telepresence 新增了路由表,给集群的 service ip 都添加了路由表。
通过路由表走 127.0.0.1 的 gateway。 那为什么用 telepresence mac 本地程序去处理呢? 因为它在启动阶段,根据路由信息添加了 ip+port 网络包处理的进程,通过端口查找进程。也就是说,所有发往这个 gateway 并且 ip 是集群 ip 的请求,都会委托给 telepresence 程序处理。
// withRouteSocket will open the socket to where RouteMessages should be sent
// and call the given function with that socket. The socket is closed when the
// function returns
func withRouteSocket(f func(routeSocket int) error) error {
routeSocket, err := syscall.Socket(syscall.AF_ROUTE, syscall.SOCK_RAW, syscall.AF_UNSPEC)
if err != nil {
return err
}
// Avoid the overhead of echoing messages back to sender
if err = syscall.SetsockoptInt(routeSocket, syscall.SOL_SOCKET, syscall.SO_USELOOPBACK, 0); err != nil {
return err
}
defer syscall.Close(routeSocket)
return f(routeSocket)
}
SSH
kubectl port-forward --namespace ambassador svc/traffic-manager :8022 :8081
telepresence 在后台启动了该命令,并且捕获了这个命令的输出,将集群里的 8022 和 8081 代理到本地的一个随机端口,8022 是traffic-manager ssh 协议监听的端口。 然后把 ssh 的随机端口保存起来,供 ssh 设置 1080 的 socks5 协议用。
func (tm *trafficManager) run(c context.Context) error {
// ......
kpfArgs := []string{
"--namespace",
managerNamespace,
"svc/traffic-manager",
fmt.Sprintf(":%d", ManagerPortSSH),
fmt.Sprintf(":%d", ManagerPortHTTP)}
// Scan port-forward output and grab the dynamically allocated ports
rxPortForward := regexp.MustCompile(`\AForwarding from \d+\.\d+\.\d+\.\d+:(\d+) -> (\d+)`)
outputScanner := func(sc *bufio.Scanner) interface{} {
var sshPort, apiPort string
for sc.Scan() {
if rxr := rxPortForward.FindStringSubmatch(sc.Text()); rxr != nil {
toPort, _ := strconv.Atoi(rxr[2])
if toPort == ManagerPortSSH {
sshPort = rxr[1]
dlog.Debugf(c, "traffic-manager ssh-port %s", sshPort)
} else if toPort == ManagerPortHTTP {
apiPort = rxr[1]
dlog.Debugf(c, "traffic-manager api-port %s", apiPort)
}
if sshPort != "" && apiPort != "" {
return []string{sshPort, apiPort}
}
}
}
return nil
}
dlog.Infof(c, "traffic manager run kubectl pf args: %v", kpfArgs)
return client.Retry(c, "svc/traffic-manager port-forward", func(c context.Context) error {
return tm.portForwardAndThen(c, kpfArgs, outputScanner, tm.initGrpc)
}, 2*time.Second, 6*time.Second)
}
mac 本地 telepresence 与集群的 traffic-manager 有一条 SSH 的 tcp tunnel。
日志里可以看出来。
time=”2021-03-14 08:43:15” level=info msg=”[pid:12] stdout+stderr > \”Accepted none for telepresence from 127.0.0.1 port 60212 ssh2\r\n\”” THREAD=/sshd
socks5
func (br *bridge) sshWorker(c context.Context) error {
c, br.cancel = context.WithCancel(c)
defer br.cancel()
// XXX: probably need some kind of keepalive check for ssh, first
// curl after wakeup seems to trigger detection of death
ssh := dexec.CommandContext(c, "ssh",
"-F", "none", // don't load the user's config file
// connection settings
"-C", // compression
"-oConnectTimeout=5",
"-oStrictHostKeyChecking=no", // don't bother checking the host key...
"-oUserKnownHostsFile=/dev/null", // and since we're not checking it, don't bother remembering it either
// port-forward settings
"-N", // no remote command; just connect and forward ports
"-oExitOnForwardFailure=yes",
"-D", "localhost:1080",
// where to connect to
"-p", strconv.Itoa(int(br.sshPort)),
"telepresence@localhost",
)
err := ssh.Run()
if err != nil && c.Err() != nil {
err = nil
}
return err
}
2021/03/14 16:28:48 connector/bridge ssh tunnel [pid:32784] started command []string{"ssh", "-F", "none", "-C", "-oConnectTimeout=5", "-oStrictHostKeyChecking=no", "-oUserKnownHostsFile=/dev/null", "-N", "-oExitOnForwardFailure=yes", "-D", "localhost:1080", "-p", "64567", "telepresence@localhost"}
2021/03/14 16:28:48 connector/bridge ssh tunnel [pid:32784] stdin < EOF
2021/03/14 16:28:48 connector/bridge ssh tunnel [pid:32784] stdout+stderr > "Warning: Permanently added '[localhost]:64567' (ECDSA) to the list of known host"… (4 runes truncated)
func (pxy *Proxy) handleConnection(c context.Context, conn *net.TCPConn) {
host, err := pxy.router(conn)
if err != nil {
dlog.Errorf(c, "router error: %v", err)
return
}
dlog.Debugf(c, "CONNECT %s %s", conn.RemoteAddr(), host)
// setting up an ssh tunnel with dynamic socks proxy at this end
// seems faster than connecting directly to a socks proxy
dialer, err := proxy.SOCKS5("tcp", "localhost:1080", nil, proxy.Direct)
if err != nil {
dlog.Error(c, err)
conn.Close()
return
}
dlog.Debugf(c, "SOCKS5 DialContext %s -> %s", "localhost:1080", host)
tc, cancel := context.WithTimeout(c, 5*time.Second)
defer cancel()
px, err := dialer.(proxy.ContextDialer).DialContext(tc, "tcp", host)
if err != nil {
if tc.Err() == context.DeadlineExceeded {
err = fmt.Errorf("timeout when dialing tcp %s", host)
}
dlog.Error(c, err)
conn.Close()
return
}
done := sync.WaitGroup{}
done.Add(2)
go pxy.pipe(c, conn, px, &done)
go pxy.pipe(c, px, conn, &done)
done.Wait()
}
本地访问远端
telepresence connect 做了什么?
- ssh 登录到远端的 traffic-manager 的 container,获取一条持久的 tcp tunnel
- ssh 启动一个 socks5 proxy server
- 启动一个 dns proxy server
telepresence intercept 做了什么?
- traffic manager 给 pod 注入一个 container,traffic-agent
- 修改 endpoint 的 port 为 traffic-agent 的 port
- ssh 反向代理把 mac 本地的端口代理到 traffic-manager
远端访问本地
当在 mac telepresence 执行了 telepresence intercept dataprocessingservice —port 3000 之后,远端的服务被注入了一个 traffic-agent,是一个 tcp proxy。 为什么能访问到 tcp proxy 呢? 其实是修改了 kubernetes 的 endpoint。 之前的 endpoint port 是 3000,现在变为了 9900 端口。
所以原本访问 3000 的端口被 9900 代理到了 mac 本地。
总结
所以当我们 curl ‘http://<集群 domain>:8080’ 的时候,dns server 可以立刻返回集群具体服务的 ip,当本地需要与集群的 ip:port 建立 tcp 连接的时候,因为路由表 NAT 网络的作用,请求转发给 ip gateway 网关。之后由 telepresence 程序处理。 curl 程序与 telepresence proxy 的一个 port 建立 tcp 连接,proxy 内存里有集群信息,与远端发起 tcp 连接,本地有个 1080 的 socks5 server 通过 ssh 的 tcp tunnel 到达 traffic-manager,traffic-manager 代理你去请求具体的远端服务。
而远端服务要访问本地,流量先到达 traffic-agent,traffic-agent 作为 tcp proxy 转发到 traffic-manager 的 反向代理端口上,该反向代理与 mac 本地建立了 tcp tunnel,从而直接路由到 mac 本地的服务上。
所以完成了本地-集群的联通性。
参考资料
阿里 https://github.com/alibaba/kt-connect
腾讯 https://github.com/nocalhost/nocalhost
https://github.com/tilt-dev/tilt
https://github.com/telepresenceio/telepresence/tree/v2.0.2
https://www.telepresence.io/