
你有没有遇到过这种场景一条 SSH 端口转发白天用得好好的一到深夜就悄悄断掉终端窗口一关映射出去的端口立刻消失网络从 Wi-Fi 切到热点整条链路就再也回不来了。我维护的某跨平台系统里有一条转发内网数据库的连接过去一个月我至少手动拉了十几次每次都以为是服务器问题结果发现是进程还活着、连接早就死了。后来我用 supervisor 把这套转发管起来配合 SSH 自身的保活参数终于做到了真正意义上的长期在线进程崩溃自动拉起、端口失效快速重连、开机自动运行。这篇文章会把我的完整思路、配置逐行解释和踩过的坑都写出来适合正在用 macOS 做远程开发、需要长期保持内网访问通道的同学参考。1. SSH端口转发总在关键时刻掉线先弄明白它为什么死1.1 一条端口转发链路的本质SSH 端口转发也就是常说的 ssh -L / ssh -R本质上是建立了一条 TCP 长连接。客户端和服务器之间先完成 SSH 握手然后在这条加密链路里塞入端口数据。本地某个端口收到请求后通过这条链路发给远端远端再转给目标地址。这里有个很多人忽略的关键点TCP 长连接不等于持久在线。恰恰相反空闲状态下的长连接是最脆弱的。SSH 协议在设计上不会没事就发数据包如果一条转发链路半小时没有业务流量中间任意一层网络设备都可能把它当成“死连接”清理掉。而客户端进程本身并没有收到任何通知依旧傻傻地以为链路还通着。1.2 三种典型的断连场景我把自己过去遇到过的断连情况整理成了表格方便对照场景表现根本原因深夜或空闲后断开端口还在监听但转发出去的数据包没有响应NAT 网关或防火墙的会话表超时静默清掉了空闲连接切换 Wi-Fi、休眠唤醒后断开ssh 进程还活着连接却已经无法收发数据底层网络路径变了但 ssh 客户端没有主动感知终端关闭、电脑重启转发彻底消失必须手动重新建立进程随终端退出且系统没有设置开机自启前两类是最坑的因为排查起来特别迷惑。ps一看ssh 进程明明在lsof一看端口也监听着但真正去连的时候数据流完全不通。你只能手动 kill 掉再重新拉起而这个过程如果发生在凌晨三点基本等于没人处理。1.3 长期在线需要满足的三个条件要让一条转发链路持续可用光靠“开着终端挂着”显然不行。我的经验是必须凑齐这三件事保活探测让 ssh 客户端定期发送心跳包。一旦发现链路失去响应立刻把进程退出而不是继续挂着一个半死不活的连接。进程守护有一个外部机制盯着转发进程。进程异常退出后自动重新拉起并且要有重试策略避免端口被占时无限死循环。开机自启电脑重启后整个链路恢复链路不需要人工介入。这三件事分开看都不难但组合起来做好就涉及到工具选型和参数细节了。2. 方案取舍launchd、nohup与supervisor谁适合当长期守门员2.1 三种常见守护方式的真实差异macOS 上让一个进程长期存活最原始的办法是nohup command 。这个方案确实能让进程不随终端退出但也仅此而已进程崩溃了没人拉网络恢复后没人重连每次系统重启后还得手动敲一遍。对于需要长期在线的转发链路来说等于没守。系统自带的 launchd 倒是能实现开机自启和崩溃重启但写 plist 配置比较繁琐重试次数、启动成功判定、日志管理这些细节远不如专门做进程守护的工具顺手。我有一次把 KeepAlive 写得太激进进程一挂就疯狂重启日志刷得飞快。supervisor 本来是我在 Linux 服务器上常用的进程管理工具macOS 上同样能跑。它的核心能力正好打中痛点配置简单、支持自动重启、有startsecs和startretries这种细粒度控制还有统一的 status 查看和日志管理。最关键的是配置格式在服务器和本地完全一致同一套思路可以无缝迁移。2.2 我选 supervisor 的三个理由第一我手里大量部署脚本都是围绕 supervisor 组织的supervisorctl status、reread、update这套操作已经在肌肉记忆里了不想在本地搞出第二套体系。第二它的故障自愈逻辑非常清楚启动后持续运行多少秒算成功、失败后重试几次、重试间隔怎么计算这些都能明确配置不像某些方案只能做“一刀切”。第三日志统一走 supervisor 管理转发出了什么问题一条tail就能看到不用去系统日志里大海捞针。2.3 安装与目录初始化如果你已经装了 macOS 上的包管理器安装其实就一条命令brew install supervisorApple Silicon 机器上装完后配置文件在/opt/homebrew/etc/supervisord.confIntel 机型则在/usr/local/etc/supervisord.conf。打开主配置底部通常已经有这样一段[include] files /opt/homebrew/etc/supervisor.d/*.ini也就是说我们只要在supervisor.d目录下放一个.ini文件就会被自动加载。先建好目录和日志目录mkdir -p /opt/homebrew/etc/supervisor.d mkdir -p /Users/你的用户名/var/log注意目录路径要写成绝对路径supervisor 配置里不会帮你展开波浪号。3. 让SSH会话先学会自救三个保活参数与转发命令写法3.1 三个必须配的 SSH 参数很多人喜欢把稳定的希望全压在守护工具上但这是本末倒置。如果 ssh 进程自己不去检测连接健康度supervisor 并不知道连接已经死了——它只看到进程还在。所以第一步先把 SSH 参数配好。-o ServerAliveInterval15 -o ServerAliveCountMax3 -o ExitOnForwardFailureyesServerAliveInterval15表示每隔 15 秒ssh 客户端会通过加密通道发送一个保活消息。这个动作很轻量但能让中间的网络设备认为这条连接还在活跃不会因为空闲而被清掉。ServerAliveCountMax3是配合前者的如果连续 3 次保活消息都没有收到服务器响应ssh 客户端就判断这条路已经不通了直接退出进程。这个参数非常关键它把“进程还活着”这个假象彻底打破让守护工具能立刻介入重启。ExitOnForwardFailureyes很多人会忽略。它的作用是如果端口绑定失败ssh 不要继续运行而是直接退出。举个例子本地 53306 端口已经被别的程序占用了如果没有这个参数ssh 会提示一句“bind failed”但进程还挂着误导你以为转发可用。这三个参数组合起来就是一套完整的“探测-判定-退出”机制。3.2 本地转发与远程转发的命令写法先说最常见的本地转发场景你的内网数据库跑在某台远端主机后面的私有网段本地需要直接访问它。命令是/usr/bin/ssh -N \ -o ServerAliveInterval15 \ -o ServerAliveCountMax3 \ -o ExitOnForwardFailureyes \ -i /Users/你的用户名/.ssh/id_ed25519_supervisor \ -L 127.0.0.1:53306:内网数据库地址:3306 \ 你的用户名远端主机-N表示不执行远程命令只做端口转发-L后面的结构是“本地监听地址:本地端口:目标地址:目标端口”。反向场景也很常见本地开发环境里跑着一个 Web 服务需要让远端的机器能访问到。这时改用-R/usr/bin/ssh -N \ -o ServerAliveInterval15 \ -o ServerAliveCountMax3 \ -o ExitOnForwardFailureyes \ -i /Users/你的用户名/.ssh/id_ed25519_supervisor \ -R 8080:127.0.0.1:3000 \ 你的用户名远端主机-R 8080:127.0.0.1:3000表示在远端主机上监听 8080 端口把流量通过 SSH 链路转发回本机的 3000 端口。多个转发可以在同一条命令里并列比如再追加一个-L参数。但我更建议关键业务和次要业务分开配置成不同 program避免一条链路挂了拖累所有转发。3.3 免密登录与密钥权限supervisor 是后台守门员它拉起 ssh 时不可能弹窗让你输密码所以免密登录是硬前提。我习惯单独生成一把专用密钥不跟日常 SSH 混在一起ssh-keygen -t ed25519 -N -f ~/.ssh/id_ed25519_supervisor ssh-copy-id -i ~/.ssh/id_ed25519_supervisor.pub 你的用户名远端主机然后检查权限chmod 700 ~/.ssh chmod 600 ~/.ssh/id_ed25519_supervisor验证免密是否生效用这条命令最干净ssh -i ~/.ssh/id_ed25519_supervisor -o BatchModeyes -o ConnectTimeout3 你的用户名远端主机 trueBatchModeyes表示禁止任何交互输入如果这条命令能顺利返回退出码 0说明免密没问题。任何一步依赖了密码输入都意味着 supervisor 接手后必然失败。4. supervisor配置逐行拆解一份可直接复用的ini4.1 配置文件结构速览在/opt/homebrew/etc/supervisor.d/目录下新建一个ssh_tunnel.ini。supervisor 会自动把它合并进主配置。之所以用独立目录而不是直接改主配置是为了方便增加更多转发任务时互不干扰。4.2 完整配置示例与逐项说明下面这份配置是我目前在用的版本直接复用时替换路径和地址即可[program:sshtunnel] command/usr/bin/ssh -N \ -o ServerAliveInterval15 \ -o ServerAliveCountMax3 \ -o ExitOnForwardFailureyes \ -i /Users/你的用户名/.ssh/id_ed25519_supervisor \ -L 127.0.0.1:53306:内网数据库地址:3306 \ 你的用户名远端主机 autostarttrue autorestarttrue startsecs5 startretries10 stopasgrouptrue killsignalTERM redirect_stderrtrue stdout_logfile/Users/你的用户名/var/log/tunnel.log stdout_logfile_maxbytes10MB stdout_logfile_backups5逐项说下我的考虑command里为什么用/usr/bin/ssh而不是直接写ssh因为 supervisor 在 macOS 上通常由 launchd 拉起这个环境下的 PATH 非常干净基本只有/usr/bin:/bin:/usr/sbin:/sbin不含/opt/homebrew/bin。直接用系统自带的/usr/bin/ssh最省心。autostarttrue表示 supervisor 起来后立即启动这个程序。autorestarttrue则是不管进程什么原因退出只要退出了就重新拉起。有人会纠结 ssh 被服务器正常断开时要不要重启我的答案是肯定要因为端口转发断开了就是故障。startsecs5的意思是进程启动后必须持续运行 5 秒supervisor 才认为它启动成功。这个参数配合ExitOnForwardFailureyes很有效端口被占时 ssh 会秒退supervisor 会判定启动失败并进入重试逻辑而不会把一次失败的启动误记为 RUNNING 状态。startretries10限制了安抚重试次数超过之后进入 FATAL 状态。如果没有这个上限守护工具遇到端口长期被占的情况就会变成一个无限重启的疯子。stopasgrouptrue和killsignalTERM是我的习惯保留项。如果 command 里直接用 ssh不太需要但一旦改成sh -c包装脚本或追加其他命令这个配置能保证 TERM 信号发给整个进程组避免留下子进程残留。日志配置我放到最后说redirect_stderrtrue把 stderr 并进 stdoutstdout_logfile指定日志路径maxbytes和backups控制单文件大小与保留份数防止日志无限膨胀。4.3 配置里最容易被忽略的两个细节第一不要在command里写-f。ssh -f会把进程 fork 到后台supervisor 跟踪的父进程会立刻退出导致它认为程序已经挂了。让 ssh 保持前台运行supervisor 才能正确管理。第二如果以后改用 Homebrew 的 OpenSSH路径要写成/opt/homebrew/bin/ssh而不要依赖environmentPATH...去解决。显式绝对路径永远是最可靠的排查问题的时候一眼就能看清。5. 开机自启与日志监控把supervisord纳入macOS体系5.1 brew services 与 launchd 的关系配置写好后先手动验证能跑通supervisord -c /opt/homebrew/etc/supervisord.conf supervisorctl status看到sshtunnel处于 RUNNING 状态后把它交给系统管理brew services start supervisor这条命令会让 Homebrew 生成一个用户级 LaunchAgent 配置交给 launchd 加载。效果是用户登录后 launchd 自动拉起 supervisordsupervisord 再拉起 ssh 转发进程。链路是“launchd → supervisord → ssh”每一级各管各的。如果你之前已经手动跑过 supervisord必须先停掉再执行上面的命令否则会出现两个 supervisord 抢同一个 socket 的冲突。5.2 日常运维操作集我平时跟转发进程打交道主要就这几个命令supervisorctl status supervisorctl restart sshtunnel supervisorctl tail -f sshtunneltail -f最常用。转发链路出问题的时候日志里会直接看到“Connection closed”或“bind failed”这类原因比到处猜快得多。修改了.ini配置之后不需要重启整个 supervisord执行supervisorctl reread supervisorctl update只对变化的配置生效不会中断其他还在运行的转发任务。5.3 开机自启后的完整验证流程我每次改完配置都会做一次完整的重启验证流程固定重启电脑 → 打开终端 → 依次执行brew services list、supervisorctl status然后检查端口监听lsof -nP -iTCP:53306 -sTCP:LISTEN如果能看到 ssh 进程监听在 53306 端口说明整条链路自愈闭环是通的。这里注意一点用户级 LaunchAgent 要等登录后才启动。如果你的使用场景要求登录之前就恢复转发需要把 launchd 配置放到系统级 LaunchDaemon 里并指定 root 运行但个人电脑上一般没必要平添复杂度。6. 上线后我踩过的几个坑从PATH缺失到重启风暴6.1 launchd 环境里找不到 ssh 命令第一次配好跑起来我满怀期待地supervisorctl start sshtunnel结果几秒钟后状态变成 FATAL。日志里写着/bin/sh: 1: ssh: not found原因前面提过launchd 拉起的进程 PATH 环境非常干净没有/opt/homebrew/bin。当时我命令里写的是ssh系统根本找不到。把command改成/usr/bin/ssh就立刻解决了。这个坑几乎每个人都会踩一次早踩早记住。6.2 密钥明明能免密登录supervisor 拉起却提示 Permission denied这个坑更诡异。我手动执行那条 ssh 命令能通但 supervisor 跑起来就是报 publickey 权限错误。排查了半天才意识到我在终端测试时依赖了系统的 ssh-agent 和钥匙串而 launchd 环境里根本没有SSH_AUTH_SOCK这个变量agent 根本不存在。解决方案是彻底摆脱对 agent 的依赖command 里用-i显式指定私钥文件同时保证私钥权限是 600、~/.ssh目录权限是 700。还有一次我把私钥放在子目录里目录权限是 755ssh 因为发现目录可被他人访问而拒绝使用把目录改成 700 后恢复正常。6.3 端口被占导致的重启风暴有一回我同时配置了两条转发其中一条要监听 8080 端口结果发现那个端口早被另一个进程占了。因为配置了ExitOnForwardFailureyesssh 启动后立刻退出supervisor 按startsecs5判定启动失败又按重试策略不断拉起。从日志看每分钟都要尝试重启好几次非常刺眼。这种场景的正确处理步骤是先lsof -nP -iTCP:8080找到占用者把端口让出来或者改掉转发配置里的监听端口同时把startretries设成一个合理值比如 10避免排查期间日志被刷爆。等环境恢复正常后再supervisorctl restart sshtunnel。我个人的体会是真正把转发链路做好三分靠配置七分靠故障处理经验。supervisor 只是把重启这件脏活自动化了但判断链路是否健康仍然要靠 SSH 参数和日志里的蛛丝马迹。我的最终建议是先用batchmode验证密钥、再验证保活参数、最后再交给 supervisor顺序不要颠倒否则每一次整链路测试都会非常痛苦。最后再分享一个实用小技巧如果有多条转发需求可以优先考虑合并进同一条 ssh 连接。一条 TCP 连接里同时挂-L和-R保活心跳只需要一份网络中断后重连也只需要重建一条链路。但要注意合并意味着链路一断所有转发都断所以关键业务和次要业务还是拆成两个 program 更稳妥。你自己判断一下手里链路的权重再决定怎么切分。