前言

很久没有写博客了,貌似最近没有学到什么新知识,都是运用已知的知识,解决各种问题和功能。没办法了,那就把最近做的一个简简单单的功能,拿出来说说吧,功能很简单,需要支持 k8s pod terminal 断开重连。

分析

我们知道,使用命令

kubectl exec -it pods/PODNAME -- bash [--kubeconfig /PATH/TO/KUBECONFIG] [--namespace NAMESPACE]

也就是exec可以执行命令的方式获取到terminal,这样就可以在本地使用kubeconfig,访问到拿到 pod 的 terminal,进而做下一步操作。

需求

如果用户需要执行的命令是个需要很久才能结束的命令,抑或者是需要长久的保持terminal 不断开,比如电脑睡眠、突然断网后,还需要保持terminal不断开。除非用户主动退出terminal。比如输入exit命令,或者kill 掉进程。
简言之:


电脑断网恢复后,能够重连 terminal
电脑休眠后,能够重连 terminal


现状

断网重连

我们知道kubectl exec获取到的terminal 会由于网络断开而断开,而如果不断网的话,k8s 是有心跳机制的,默认5s发送一次心跳,见代码
vendor/k8s.io/client-go/transport/spdy/spdy.go:37

upgradeRoundTripper := spdy.NewRoundTripperWithConfig(spdy.RoundTripperConfig{
		TLS:                      tlsConfig,
		FollowRedirects:          true,
		RequireSameHostRedirects: false,
		Proxier:                  proxy,
		PingPeriod:               time.Second * 5,
	})

因此,如果不断网的话,讲道理 terminal是不会断掉的,而如果是断网重连,我们可以通过terminal断开后,网络情况决定是否重新链接,也就是,如果terminal断开了,首先检查一下网络,如果网络不通,则重连,很容易实现。
伪代码

spdyExecutor, err := remotecommand.NewSPDYExecutor(config, method, url)
	if err != nil {
		return err
	}
	return spdyExecutor.Stream(
		remotecommand.StreamOptions{
			Stdin:             stdin,
			Stdout:            stdout,
			Stderr:            stderr,
			Tty:               tty,
			TerminalSizeQueue: terminalSizeQueue,
		},
	)

这里返回的时候,判断err 是否为空,如果不为空,则判断和apiserver时候能建立正常连接来决定是否重连。

睡眠后醒来重连

这个就稍微有点儿难做了,睡眠后会断网,断网terminal 会断开。而睡眠结束后,网络也立即恢复了,因此无法通过网络连接情况的决定是否重连,同时如果系统睡眠,则程序会挂起,而程序如果挂起,那么判断程序是否挂起的逻辑是无法生效的。也就是说,程序不知道自己是不是处于睡眠中。

奇思妙想一

时间。如果挂起,则程序就不在运行态了,那么程序内部的时间和物理时间是对不上的。会产生时间偏移。可以通过这个偏移来判断是否产生了睡眠。具体做法可以是:进入termianl之前,记录开始时间,同时在程序内部开启一个goroute,每过一秒加1,当terminal 返回的时候,获取到当前时间。使用当前时间 - 开始时间 == 计时器

实验失败

很奇怪,这个偏移对不上,明明电脑可能睡了10分钟,但是偏移量可能只有一两秒。

奇思妙想二

获取系统睡眠事件。也就是在程序中监听系统事件,讲道理,如果程序挂起,应该会收到挂起事件和恢复事件。伪代码

signals := make(chan os.Signal, 5)
	signal.Notify(signals, os.Interrupt, syscall.SIGHUP, syscall.SIGSTOP, syscall.SIGTSTP, syscall.SIGCONT)
	<-signals
实验失败

很遗憾,没收到任何一个信号,不知道为啥。也有可能是当时没弄好,也许是可以拿到的。。

奇思妙想三

看远码,改源码。看一下断开是如果断开的,通过翻看代码,找到了一点儿蛛丝马迹
/Users/naison/go/pkg/mod/k8s.io/client-go@v0.21.2/tools/remotecommand/errorstream.go:36

func watchErrorStream(errorStream io.Reader, d errorStreamDecoder) chan error {
	errorChan := make(chan error)

	go func() {
		defer runtime.HandleCrash()

		message, err := ioutil.ReadAll(errorStream)
		switch {
		case err != nil && err != io.EOF:
			errorChan <- fmt.Errorf("error reading from error stream: %s", err)
		case len(message) > 0:
			errorChan <- d.decode(message)
		default:
			errorChan <- nil // <--在这里
		}
		close(errorChan)
	}()

	return errorChan
}

这里会开启一个errorstream,同时从stream中获取事件,如果有事件,则decode出消息
/Users/naison/go/pkg/mod/k8s.io/client-go@v0.21.2/tools/remotecommand/v4.go:82
这里可以看到如果消息是成功断开的,则返回nil,异常情况则返回err,但是

default:
			errorChan <- nil // <--在这里

这里如果是睡眠导致的网络断开,那么读一个已经关闭的连接,就会返回io.EOF,而,EOF会走到默认分支,返回一个nil,因此就导致了terminal return的时候,err是空的。

实验成功

default:
			errorChan <- nil // <--在这里

改为

default:
			errorChan <- fmt.Errorf("disconnect should notify by remote server") // <--在这里

这样就好了,然后return返回的时候,就可以拿到err,从而进行重新连接了。

解决方案

就是上面提到的修改remotecommand的源码,该一行代码即可。
伪代码

func Terminal(podName string, containerName string, shell string) error {
	t := term.TTY{Raw: true}
	t.MonitorSize(t.GetSize())
	// remove log eof
	k8sruntime.ErrorHandlers = k8sruntime.ErrorHandlers[1:]
	first := true
	go func() {
		for {
			cmd := shell
			if first {
				cmd = fmt.Sprintf("clear; %s", shell)
				first = false
			}
			// 进入terminal的操作,spdy写流即可,就不贴代码了
			err := Exec(podName, containerName, []string{"sh", "-c", cmd})
			if err == nil {
				os.Exit(0)
			}
			time.Sleep(time.Second * 1)
		}
	}()

	return t.Safe(func() error { select {} })
}

后记

这个简单的功能,让我了解到

  • 断开terminal链接的操作,应该是由远端通知过来的。因为本地只是不停的监听标准输入,然后写到和api-server建立的链接中,所有的命令都是在远端运行的,即便是exit命令,也不例外。
Logo

K8S/Kubernetes社区为您提供最前沿的新闻资讯和知识内容

更多推荐