随着美国 COVID-19 单日新增病例超过 66666 例,早日能够正常回到 office 上班的愿景就越来越渺茫了,于是把精力放在了改善在家工作的舒适程度上。这里记录和分享一些远程连接相关的工具和小 tip,希望对大家 work from home 也有一些帮助。

远程连接断线重连

远程连接的基本工具 SSH 相信大家都时不时会用到,不过在 WFH 的过程中每天都会大量用到的情况下,了解一些小 tip 还是会让使用过程愉悦很多。使用 SSH 最常碰到的麻烦事就是网络断开了,比如中午吃个饭电脑休眠了,再唤醒电脑的时候原来的 SSH 连接就很有可能不好使了。这个时候有可能会碰到下面一些情况。

网络断开了,但是 SSH 还没有发现这一点,处于无响应的状态,按 Ctrl+C 之类的什么键都没有反应,如果等待一段时间 SSH 发现网络断开了,会自动退出,这个时候可以重新连接,但是如果不想等待,似乎只有关掉终端窗口或者 tab(或者 kill 掉本地打开 SSH 的那个 shell)这些办法。起始还有一个简单的办法,就是按一下回车键,然后输入 ~.,SSH 就会立即断开。这里用到了 SSH 的 escape 命令,详细可以参考 ssh 文档的 Escape Characters 一小节,除了断开连接之外,还能做很多其他事情,比如我们之后会讲到的打开 tunnel。

SSH 连接断开了,但是终端现在处于非常奇怪的状态,比如切换窗口或者 tab 的时候会不停地发出 beep 的声音,或者是在终端上答应出一些奇怪的诸如 ^[[I^[[O^[[I^[[O 之类的字符串。这通常是因为在远程连接那边的某些程序(比如 tmux 或者 VIM)开启了鼠标事件或者焦点切换事件的 reporting,但是由于网络连接中断导致 SSH 连接断开了,所以没有正常关闭这些事件的汇报,可以通过 reset 的方式来恢复终端的正常状态——在 iTerm2 下面可以使用 Cmd+R 来实现。

最好的状态是 SSH 正常断开了,终端也没有进入奇怪的状态,这个时候只要重新连接 SSH 就可以了。不过重新连上去之后你会发现原来运行的命令由于 SSH 的断开也同时被 kill 掉了(包括通过 & 放在后台运行的进程),可能你的编辑器没有正常退出,文件没有保存,或者程序跑到一半挂掉等等,总之非常麻烦。好在这个已经是多年以来大家都会碰到的问题了,有许多工具可以帮忙解决。

第一类工具是从断线重连的角度来解决问题的,最简单的工具可能就是 autossh,功能上类似于维护一个 while 循环,如果发现 ssh 断开了就自动重新连接,但是没有其他的功能,比如你在远程 server 上运行的程序如果 ssh 中途断开了,程序也会跟着终止运行。

更加高级一点的工具是 Mosh: the mobile shell,我在之前的一篇博客中也安利过 mosh,之前上学的时候一直用它。Mosh 特别适合于在笔记本上使用,它除了有基本的断线重连功能(通过特殊协议和 server 上的 mosh 服务器通讯,所以网络断开之后服务器上运行起来的程序也不会终止运行),还支持诸如“漫游”之类的功能:比如说你在家里通过 mosh 连上了服务器,然后让笔记本休眠了,跑去咖啡店通过咖啡店的 wifi 再连上网(这时你的 ip 等各种网络参数都变了),会发现你的 connection 又自动恢复了,之前打开的程序也还在。

mosh 底层还是使用 SSH,但是它有一些自己的 protocol 来做 bookkeeping,需要同时在客户端和服务器上安装 mosh 才能运作,如果服务器上没有 root 权限的话也可以只安装在自己的 home 目录。不过如果所处的网络环境有一些特殊的配置,比如比较严格的防火墙,或者自动代理之类的,针对 SSH 本身做了优化,但是并没有专门针对 mosh 的 communication 进行支持,可能 mosh 就无法使用了,这也是我工作之后没有再经常用 mosh 的原因之一。此外 mosh 还有一些其他的功能限制,比较重要的一个就是不支持端口映射。SSH 的端口映射是一个非常有用的工具,这个我们下面也会详细介绍。另一个看起来跟 mosh 比较类似的工具叫做 Eternal Terminal,看起来功能似乎更强大一些,还支持 tmux control mode(这个等下也会提到),不过我自己并没有用过。

另一类解决办法是通过 server 端的 session 管理来实现,允许网络连接随便断开,断开之后只要手工重新连接就好了,但是提供一种方式可以让用户断开连接之后之前的程序还能继续运行,并且重新连接之后还能恢复到之前的工作环境。由于不需要在网络协议之类的地方做过多修改,因此兼容性很强,凡是支持 SSH 的环境基本上都能用。比较老牌的工具是 GNU Screen,我只在很久以前用过,现在主要用一个稍微新一点的叫做 tmux 的工具。这类 Session 管理工具不仅能让你的程序在网络连接断开之后继续运行,而且还能开启多个“窗口”(见下图,虽然在这个 UI 里看起来更像多个 tab),能够方便地来回切换,比如非常常见的 workflow 就是 SSH 到服务器上之后需要开一个 tab 来写程序,另一个 tab 用来运行命令之类的。当然,如果需要的话,也可以进行分屏等高级操作。

网络重新连接之后,可以 attach 到原来的 session,就会又回到这里,原来的 tab 也都还在,命令行历史这些也都还保存着,甚至还能回滚看到之前的 shell 命令输出的结果。因为网上可以找到很多很好的 tmux 的介绍和 tutorial,我这里只简单介绍一下我的 workflow。tmux 直接运行在 server 端,大部分系统都能直接通过相应的包管理器安装,安装好之后可以做一些基本的配置,放在 ~/.tmux.conf 文件里,我的配置文件如上图所示,我用了 Tmux Plugin Manager (tpm),所以还需要根据 tpm 网站上的安装指南安装一下 tpm(git clone 一下)。除此之外主要的配置一个是增大回滚 buffer(会占用更多内存),让我可以看到更多的命令行输出历史,还有就是设置 prefixCtrl-l(默认好像是 Ctrl-a)。

配置好之后就可以直接运行 tmux 启动了。如果使用 tpm 配置了插件,那第一次启动的时候插件还没有正常安装,需要在 tmux 里按 prefix + I 来安装配置文件里指定的插件。tmux 几乎所有的命令都是通过 prefix 来执行的,比如 prefix + c 是创建一个新的 window(tab),按法是先同时按下 Ctrll(或者任何你自己指定的 prefix 组合键),放开之后再 c。我比较常用的命令是:

网络断开等原因导致 tmux 退出的时候 session 也可以自动 detach,下一次再 SSH 连回服务端的时候就可以运行 tmux a 来 attach 到之前的 session,所有的窗口会恢复如初,非常简单也非常好用。如果需要的话,也可以创建不同的 session,通过不同的名字来区分,详细可以参考 tmux 的文档。另外还有一个小帖士是,tmux 可以运行多个 client 同时连接到一个 session 上,有时候可能你在另一台电脑上连上了或者你之前网络无响应之后程序没有及时退出导致的残留 client,会有几个不同的 client 连到同一个 session 上,如果几个 client 各自的终端窗口大小不一样,就会出现不一致的情况,有可能你会看到屏幕上只显示一小块区域,其他的全是白点(另外的 client 窗口比较小),或者是超出屏幕范围的内容直接被截断了(另外的 client 窗口比较大),这个时候可以通过 prefix + D 来强制 detach 其他的 client。

远程代码编辑

远程代码编辑最简单的方式就是直接在 SSH 之后在 server 端启动一个终端文本编辑器,比如 VIM、Emacs 或者甚至 nano 之类的。我一直比较不喜欢在终端里运行编辑器(包括在本地的终端),虽然现在似乎有一些办法可以让鼠标、剪切板之类的也能和终端程序互动,但是在 SSH 套 tmux 再套编辑器的情况下有时候情况就很混乱了,最麻烦的就是需要复制或者粘贴超过一行的代码的时候了。另外就是取决于网络的质量,远程代码编辑会卡卡的,如果网络 latency 比较大,网速再快也是无济于事的。所以我通常只在做一些小修小改的时候才会直接在服务器上打开编辑器。

另一种做法就是在本地编辑,然后把代码同步到服务器上,我在上学的时候知道有人是直接通过 Dropbox 之类的工具来自动同步的。好处是不需要自己麻烦什么,坏处主要是你也不知道 Dropbox 什么时候同步完成了,比如你在 debug 的时候修改了一个东西,然后现在到 server 上去运行,如果你运行的时候最新的代码还没同步过去,那可能就会非常 confusing。相对比较容易掌控一点的做法是手动同步,有人通过 git 来同步,我觉得有点不太合适,除了麻烦很多之外还会导致 git repo 里出现很多细碎的 commits,比如在 debug 的时候可能甚至会出现许多无法编译或运行的代码被 push 进去。比较合适的工具是用 rsync 通过 SSH 来传输文件,能够增量只同步修改过的文件,也不需要专门在服务端安装什么程序。如果觉得在本地编辑完之后手工 rsync 一下太麻烦的话,也可以再多加一个 automation,通过监视本地某个文件夹内的文件变化来自动触发 rsync,这样在编辑器里保存文件的时候同步就自动完成了。在各个系统下都有许多工具可以用来监视文件系统变化,下面是用跨平台的 fswatch 作为一个例子:

fswatch -o local-dir | while read f; do 
    rsync -azP local-dir/ server:path/to/remote/dir
done

有一个小帖士就是在使用 rsync 的时候,指定目录路径的时候末尾加不加 / 会代表不同的意思(“该目录”还是“该目录的内容”),我不太能记住哪个是哪个,所以一般保存一段我知道可用的命令行,在不同的情况下修改一下就可以用。如果想搞清楚可以直接参考官方文档,或者找一下网上其他专门介绍这个的博客文章(比如这篇)。

第三个选项是直接使用 web based editor 在线编辑远程文件,有的公司可能整体部署了开源的或者甚至自己开发的 online IDE 给员工使用,这样通常是最方便的,因为所有东西都配置和集成好了。如果需要自己搞的话,可能需要对服务端有比较大的控制权限,总之听起来好像有点麻烦,我没有这个需求也就没有尝试过。不过这里有一个小 hack:Jupyter Notebook 其实是可以直接打开 Python 文件进行在线编辑的(可能其他 Jupyter 支持的语言的代码文件也可以),所以一个临时解决方法是在服务端运行一个 Jupyter notebook,然后通过 SSH 隧道(后面会讲)进行端口映射,这样就能在本地打开远程的代码进行编辑了。至于更完整的解决方案,这里只贴一个看上去还不错的(似乎是基于 Visual Studio Code 的)开源项目 Theia,和一个在线编辑器资源列表合集供大家参考。2020.07.13 Update: ZT 在评论里推荐了 code-server,基本上就是一个 web 版的 VS Code,试了一下,下载对应的预编译包基本上直接解压就可以使用了(可以结合 SSH 隧道),非常方便。

还有一个选项是 SSHFS 远程文件系统,我个人不是很喜欢这个方法,感觉是在条件不允许的情况下提供一种无缝衔接的假象,编辑器之类的都会将远程文件当作本地文件来对待,这样连接如果突然中断的话,程序有可能会崩溃或者卡死,文件会处于什么样的状态也非常不确定。

最后就是直接使用编辑器的远程编辑功能了。VIM 可以通过 netrw 之类的插件直接打开远程文件进行编辑,Emacs 也有一个很强大的 TRAMP (Transparent Remote (file) Access, Multiple Protocol) 系统。我在本科的时候用过 TRAMP,至少那个时候还没有觉得特别“Transparent”,比如有时保存文件的时候还是会卡一阵,可能因为(当时)Emacs 没有异步功能吧。我最近发现 Visual Studio Code 有一个 Remote - SSH 扩展,尝试了一下非常好用,比我多年前记忆中的 Emacs 下的远程编辑系统好用太多了,直接打开 server 端的一个目录或者项目,之后就跟本地操作几乎没有任何区别。如果网络断掉了编辑器也不会进入莫名其妙的诡异状态,而是会自动禁用所有操作,然后提供按钮可以重新尝试建立连接(重新联网之后使用),或者退出编辑器。和其他诸如终端之类的扩展也能互相兼容。另外我发现 VS Code 还有其他不少高质量的扩展,易用性比其他编辑器要好很多,同时又不像完整 IDE 那么臃肿,速度也还不错。不过 VS Code 上的 VIM 模拟器也是我用过的最差的,虽然功能看起来比较全,但是经常会进入一些莫名其妙的状态(比如你 somehow 既按了 VIM 的 u 来 undo,又按了 Cmd+Z 之类的),会导致代码丢失之类的,非常糟糕,这也是我一直没有把 VS Code 当作主要编辑器的原因。

总之,我现在的情况是:如果是小文件一次性编辑,就直接在服务端打开终端文件编辑器;如果是要写很多代码或者编辑很多文件的话,所在的环境有配置好在线编辑器就用那个,否则的话会用 VS Code 连过去。

SSH Multiplexing

SSH Multiplexing它允许 SSH 重复利用一个已经 authenticate 过的连接来建立多个 session,这样比如你已经有一个 active 的 SSH connection 的情况下,如果再运行 scp 之类的命令就可以重复利用已经有的连接,所以即使系统要求 two factor authentication 之类的复杂验证步骤也不会每次都触发。使用方法就是在本地 SSH 的配置文件 ~/.ssh/config 里加入

Match host my-server.my-domain.com
  ControlMaster auto
  ControlPath ~/.ssh/ctrl-%C
  ControlPersist yes

这样第二次 ssh、scp 之类的时候就可以不用重复认证了。

SSH 隧道和端口映射

SSH 另一个常用功能就是通过 SSH 隧道建立端口映射。在高层网络协议中一个 Domain Name(或者 ip address)加上一个端口组合在一起唯一确定了一个服务的“地址”。我们平时在连接网络服务的时候通常都不需要输入端口,是因为常见的服务都有默认的端口,比如通过浏览器浏览网页的时候会使用 HTTP 服务默认的 80 端口,通过 SSH 连接服务器的时候会使用默认的 SSH 服务端口 22,等等。端口映射简单地来说就是通过 SSH 建立一个隧道将 SSH 连接一头的一个端口和另一头的一个端口“等价”起来。

其中最常用的一种端口映射是 Local Forward,可以在 SSH 连接的时候通过如下命令行参数指定:

ssh -L 8080:localhost:80 ssh.server.com

其中第一个 8080 是指客户端的 8080 端口,之后的 localhost:80 需要在服务器端的上下文中解释,所以这里虽然是 localhost 但是其实是指服务器自己,所以并不是指客户端。建立好端口映射之后,在客户端上连接(比如在浏览器中打开)localhost:8080 (注意这时的 localhost 是在客户端的上下文中解释的,所以也就是客户端自己),这个时候所有通讯会通过 SSH 隧道和服务器进行连接,对客户端而言,其结果相当于直接连上了服务器(这个例子中是 ssh.server.com)的 80 端口)。为什么要绕这么大一个弯,而不直接连接服务器的 80 端口呢?因为很多时候服务器是在防火墙背后的,可能除了可以通过某种方式能建立一个 SSH 连接之外其他端口都被防火墙阻隔,但是如果通过 SSH 隧道连接的话,实际上这个连接是从 SSH 服务器本身发出来的,在我们的例子中其实就是服务器本机连接本机,所以通常都不会被防火墙阻隔。除此之外,端口映射的目标主机也不一定非要是 SSH 服务器本机。例如下面的简图所示,假设每个机器有三个端口,机器 ssh.client 通过 SSH 连接到 ssh.server(的端口 1),并通过 -L 2:main.server:3 建立端口映射。这个时候如果 main.client 去连接 ssh.client 的端口 2,对于 main.client 来说,其结果好比直接连上了 main.server 的端口 3,虽然 main.server 处于防火墙之内。在一开始的例子里 ssh.server 和 main.server 是同一台机器,ssh.client 和 main.client 也是同一台机器。

和 Local Forward 相对应的还有一种叫做 Remote Forward,语法是类似的,通过 -R 8080:localhost:80 命令行参数来实现,这样在连接 SSH 服务器的 8080 端口的时候,就好比瞬移到了 SSH 客户端机器然后连接其 localhost:80 一样。听起来有点复杂,那这些 SSH 隧道在实际中能做什么事情呢?

听起来有点复杂,实际中的用处主要是用来绕过防火墙(正向映射)或者访问私有局域网(反向映射)。例如我们现在通过 SSH 连接到一个服务器,想要复制一个文件回客户端,正常的做法是回到客户端另外开启一个终端,然后通过 scp 再次建立 SSH 连接来复制文件,如果路径比较长的话,输入起来比较麻烦(毕竟涉及到远程路径的时候命令行自动补全也不好用了)。这个时候我们可以从服务端运行 scp 反向连回客户端来推送文件,不过通常我们的客户端可能在私有子网中(例如家庭 WIFI)无法直接访问,这个时候就可以通过反向端口映射来实现。类似于下面这样的,dollar sign 前面的信息表示我当前所在的机器的 hostname 和当前路径:

ssh.client ~$ ssh -R 2222:localhost:22 ssh.server
ssh.server ~$ cd some/complex/path
ssh.server ~/some/complex/path$ scp -P 2222 file localhost:

这样就算客户端机在一个私有子网内也可以正常反向 scp 了,当然客户端机器必须配置好允许 SSH 连接(MacOS 默认是关闭的),如果想省去每次输入密码的话,也可以将服务器端的 SSH public key 加入客户端机器的 ~/.ssh/authorized_keys 里,让连接更方便。

正向端口映射的常见用途是比如你在服务器上运行了一个 Jupyter Notebook 之类的程序(假设启动在端口 8888),但是你的服务器又在防火墙之内,你就可以通过把客户端的某个端口(比如 9999)映射到服务器的 localhost:8888 端口上,然后就可以在客户端的浏览器里通过 localhost:9999 访问了。

如果有很多常用的端口映射,可以直接写到 ssh 的配置文件里:

Match host my-server.my-domain.com
  # Jupyter Notebook
  LocalForward 8888 localhost:8888
  # Tensorboard
  LocalForward 6006 localhost:6006

不过这样有一个麻烦是每次建立新的 SSH 连接的时候都会尝试做端口映射,但是如果端口已经(被前一个 SSH)占用了,就会打印出端口映射失败的信息。还有一个方便的做法是在已经有的 SSH 连接上新增隧道。前面我们有提到过 SSH 的 escape command,在 SSH 连接里按回车,然后输入 ~C 就可以进入命令模式,在这里输入诸如 -L 8888:localhost:8888 之类的命令就可以在已有的 SSH session 里建立新的隧道,不过如果你开启了 SSH Multiplexing 的话,就不支持这么搞了。

tmux -CC

最后想提一下的是 tmux 有一个叫做 control mode 的东西,可以通过 -CC 命令行参数来启动,比如原来 attach 到已有的 session 的命令 tmux a 现在变成 tmux -CC a。Control mode 可以让支持的终端接管 tmux 的 tab UI,例如下图是 iTerm2 接管之前展示过的 tmux session 之后的样子。

值得注意的是 tmux 是运行在 SSH 连接的服务器上的,使用 control mode 之后它们看起来像本地终端的 tab 了,为了避免混淆你可以设置 iTerm2 让他在开启 tmux 窗口的时候使用 tmux profile,这样通过设置 tmux profile 下的颜色和字体,就可以很方便地区分本地终端和远程终端了。让 iTerm2 接管的好处除了看起来比较好看之外,主要就是可以直接使用 native GUI 的操作来切换、新建和关闭 tab,而且现在可以直接通过鼠标回滚查看命令行历史了,不需要用 tmux 的奇怪的 prefix + [ 快捷键再慢慢 PageUp 的方式来回滚了。不过尴尬的是据我所知目前为止支持 tmux 的 control mode 的终端好像只有 Mac 下的 iTerm2 这一个。

以上就是我的 WFH 远程连接工具箱和小贴士,祝大家 WFH 顺利!😃