ssh接続の切断-各種原因と対策

概要

リモートマシンの操作に欠かせないssh接続は様々な原因により切断される可能性があります。しかし、原因によっては、適切な対策をすることで、接続を維持することも可能です。

この記事では原因と対策についてできるだけ網羅的に紹介します。

注: ssh接続の「維持」について

先ほどssh接続を「維持」できると言いましたが、物理的に接続が通っていない状態で通信することはできるはずがありません。

では、どういう状態が「維持」と呼べるのかというと、接続が通っていない間は一時的に大きな遅延が挟まり、その後にまた以前と同じ正常な接続状態に戻るということです。具体的な動作としては、物理的に切断されている間は文字を入力しても表示されず、向こうからの応答が返ってくることもありませんが、復帰後にそれらがまとめて送信あるいは受信されるという感じになります。

sshコマンドが終了されて認証からやり直さなければいけない完全な切断と比べると、コマンド実行や入力の作業をそのまま続けられるので便利です。

完全な切断ではないものの一時的に通信が停止した状態を指すときは、この記事では「中断」などの言葉を使います。

原因1: TCPパケットのタイムアウトによる切断

TCPにおいては、確立した通信においてデータ(パケット)の送受信が成功しない状態が一定時間にわたって続くと通信を切断することが定められています。ssh接続は単一のTCP接続のうえで動作するので、TCP接続が切断されればssh接続も切断されます。切断されるのはあくまでデータの送信が実際に失敗したときだけであるため、ssh上で何も操作を行っていない場合には(実際には通信が不可能な状態にあったとしても)より長時間にわたって接続が持続する可能性があります。

切断の具体的な動作としては、送信したパケットへの応答が一定時間にわたって返ってこなければ、正常な通信中に測定した遅延時間をもとに、間隔を毎回2倍にしながら何度か再送信し、上限回数を超えたら接続が切断される、という感じになります。ただし、2倍にしていくといっても、上限値が定められている場合もあります。これらのパラメータは各システムやユーザーがある程度自由に設定できます。

Linuxでは /proc/sys/net/ipv4/tcp_retries2でリトライ回数が定められていて、デフォルトで15に設定されているようです。また、再送信の間隔の上限はTCP_RTO_MAXという変数で120と定められており、結果的には20分程度で接続が切断されます。なお、TCP_USER_TIMEOUTという変数によりユーザーレベルでリトライ時間を設定できるという情報(Technical Memorandum: TCPの再送タイムアウトを制御したいTCPの再送タイムアウトを制御したい #Linux - Qiita)もありましたが、sshでこれを使う方法があるかは不明です。

Windowsでは、リトライ回数はレジストリのHKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\Tcpip\ParametersキーのTcpMaxDataRetransmissions値において設定されます(公式ドキュメント: Windows XP の TCP/IP および NBT 構成パラメーター - Windows Client | Microsoft Learn)。デフォルトでは値が存在しませんが内部的に5に設定されています。これはかなり小さく、20秒ほどタイムアウトに達します。VPN使用時にWi-Fiのアクセスポイントを変更したり、地下鉄でモバイル回線を使ったりすることを想定すると若干不安な数値です。手元ではTcpMaxDataRetransmissions10(0x0000000a)に設定することで1分程度の切断には耐えるようになりました(regeditではデフォルトで16進数入力なので注意)。また、情報が古いですが、TCP/IP の再送タイムアウトの最大値を変更する方法 - Microsoft サポートによると上限値として240秒が設定されているようです。これを変更する方法はわかりません。

Macはおそらくnet.inet.tcp.rexmt_threshがリトライ回数を定めているようです。rexmtのxはtransの略だと思われます。

再送信のアルゴリズムの都合上、パケットが実際に送られてssh上で通信再開が確認できるまでには、物理的な通信が再開した後さらに数十秒待たなければならない場合があります。もちろん、実際に切断されたままになっているときのタイムアウトまでの時間も非常に長くなる可能性があります。リトライが多くなると負荷がかかりそうなので、回数の上限は10-15くらいにしておくのが無難そうです。Linuxでも、デフォルトより小さくしておくべきだという意見があるくらいです。

名前が似ていますがWindowsTcpMaxConnectRetransmissionsなどは接続を開始するときのSYNパケットの動作を決めるものなので、確立されたssh接続の動作とは関係ありません。KeepAliveInterval、KeepAliveTimeあたりも関係ないと思います(効果は無さそうでした)。Windowsを例に出しましたが、他のOSでも同様です。

原因2: ネットワーク環境の変化

接続するネットワークを変更して使用するIPなどが変わった場合は、当初確立したTCP接続が利用できなくなるため、通信が成立しなくなります。これが長時間にわたって続くと、前節のようにssh接続が切断されます。

該当するのは例えば以下のような場合です。

光回線からモバイル回線に切り替えた
・プロバイダによってグローバルIPが変更された
・2重ルーター環境において外側のルーターから内側のルーターに切り替えた
・PCで無線から有線に切り替えた(アダプタのMacアドレスが異なるため、異なるIPが割り当てられるはず)
・PCがスマホ経由でインターネットにアクセスしているときに、スマホの接続先を家のWi-Fiからモバイル回線に切り替えた(PC自体のIPアドレスは変わっていないがスマホから先の経路が変わっている)

一方で、以下のような場合は該当しません。

・全く同じネットワーク(SSIDが同じWi-Fiや、同じLANケーブル)に再接続した
・同じWi-Fiルーターの2.4GHzから5GHzに切り替えて、同じIPが割り当てられた
Wi-Fiの親機から(その親機に接続された)ブリッジモードのルーターや中継器に切り替えて、同じIPが割り当てられた
スマホ経由でアクセスしている場合にスマホの接続先アクセスポイントを2.4GHzから5GHzなどに切り替えて、同じIPが割り当てられた

これらの場合は、物理的な帯域・経路が変わるだけで、IPアドレスは変わっていないため、以前と同じTCP接続を引き続き利用できます。

ネットワークが変更された場合も同じ接続を引き続き使うことは可能ですが、理論上、「接続先の協力」が不可欠です(多分)。本来はIPレベルで全く別の経路を通ってきているわけなので、それを先ほどと同じものと認識するためには接続先で通信を識別するための工夫をしなければいけないということです。ssh単体にこのような機能はありません

有効な対策は、VPNを使って通信経路を仮想化すること、あるいはmoshEternal Terminal (et)のようなsshの代替となる別のソフトウェアを使うことです(sshを使わないのは「sshの維持」の厳密な意味からは外れますが)。これらを接続先で設定するためには、root権限Windowsなら管理者権限)や、場合によってはネットワーク管理権限も必要になるでしょう。

VPNは、L3かL2レベルで動作するとされているものなら、理論上は(=クライアントの実装がちゃんとしていれば)なんでも大丈夫だと思います。筆者が試したのはTailscaleだけですが、(Tailscaleのバックエンドである)Wireguardそれ自体、あるいはOpenVPN(や、それを使うVPNサービス)などでもできるはずです。これらを介して接続している場合は、通信に使用するのはVPN専用の仮想的なアダプタおよびIPなので、物理的な接続が変更されてもネットワーク環境は仮想的にそのまま維持され、ssh接続も維持されます。この方法だと、rootは要りますが、ネットワーク管理権限は無くてもいける場合が多いでしょう。例えばTailscaleは双方がSymmetric NAT環境の中であってもリレーサーバーを使ってVPN接続を維持してくれます(過去記事を参照)。

moshやEternal Terminalに関しては使ったことがないのであまり詳しく紹介できません。moshのほうが古く、UDPを使用していて、必要な最大接続数に応じて複数のポートを開放する必要がありますが、TCPパケットのタイムアウトによる切断の問題を完全に回避できるかもしれません。Eternal Terminalのほうが新しく、TCPポートを1つ開ければいけるようです。これらを動かす自体にroot権限は要らないかもしれませんが、ポートを開放するためにはネットワークの管理権が必要で、これは一般にはroot権限よりむしろ厳しい条件でしょう。

プロキシサーバーを使う

先ほど「接続先の協力が不可欠」と述べましたが、これはあくまで手元のPCと接続先という2つのコンピュータのみを想定したときの話です。実際には「接続が不安定な部分」さえVPNで保護できればいいので、「接続先」それ自体ではなく「接続先への安定した経路を持っている何らかのPC」の協力があれば目的は達成できます。要するにプロキシサーバーを経由するということです。当然、そこでの管理者権限/ネットワーク権利権限は必要です。

例えばプロキシサーバーにVPNだかmoshだかEternal Terminalを入れておき、プロキシサーバーから最終的な接続先にssh接続してローカルポートフォワード(-L)してプロキシサーバーの10022番ポートとかで接続先のsshサーバーが見えるようにすれば、あとは手元の端末からVPNとかmoshとかを経由してその10022番ポートに接続する(moshとかでできるのかは知りませんが)ことで、安定的なssh環境が手に入ります。

あるいは、プロキシサーバーでsshトンネル(ローカルポートフォワード)を実行するのではなく、プロキシサーバーにHTTPS・SOCKSプロキシなどを立てたりsshサーバーを立てたりしてProxyJumpあるいはProxyCommandを使用して手元からつなぐという手もありますがこの場合ちょっと不利になることがあります(後述)。

プロキシサーバーを稼働させるには相応のコストがかかり、立地によっては通信品質が低下する可能性もあるので、その辺のデメリットも考慮した上で使うことになります。

原因3: NATテーブルのマッピングが消滅する

インターネット越しの通信などNAT機器の内側から外側へとsshをしている場合には、ルーターのNATテーブルの持続時間に注意が必要です。

ssh接続で定期的にやり取りを続けている間は、外側から来るパケットはNATテーブルが保持するマッピングによって内側まで通されますが、一定時間全くパケットが来ないとマッピングが消滅し、同じポートを使うことができなくなるので、通信が中断(いずれは切断)されます。一度消滅したマッピングを復活させることはできません。

一般にTCPに関するNATマッピングの持続時間は他のプロトコルよりは長めに設定されていますが、それでも数十分-数時間程度です。

NATマッピング消滅による切断を防ぐには、定期的に無意味な通信を行うことが有効です。.ssh/configのServerAliveInterval値により定期的にパケット(keep-aliveパケット)を送信することができるほか、toppingのような定期的に出力を行うプログラムを実行するという方法もあります。逆に言えば、NATが介在しない経路(VPNも該当する)でのssh接続であればServerAliveIntervalなどは通常不要です。

あるいは可能であればルーター側でNATテーブルの持続時間を長くするという方法もあります。長すぎるとポートが枯渇するおそれもあるので注意が必要です。

なお、ルーターの静的NAT設定を使って固定化したポートを使って通信するという案(sshのoutgoingポートを固定するのはHow can I set the source port for an SSH command-line client? - Server Faultみたいにnetcatを使えばいけそう)も思いつきましたが、色々と面倒そうなのでやめました(試してもいません)。とはいえ、多機能なルーターなら、ssh接続に使うマッピングだけ長期間保持するような機能を実現できる可能性は無くはないかもしれません。

原因4: スリープ/ハイバーネートでkeep-aliveパケットが送れない

先ほど紹介したServerAliveIntervalやtopなどのパケット送信手段ですが、手元のPCがスリープしている時にはこれらの送信も停止してしまいます。ルーターの設定を変更できれば多少は緩和できますが、限界があります。

すなわち、NATマッピングの保持時間を超えるような長時間のスリープを挟んでもssh接続を維持したければ、VPNを使って仮想的にNATを回避する必要があります。別の言い方をすれば、NATマッピングが消滅してしまうということは、別の経路でTCP接続をやり直さなければいけないので、「原因2: ネットワーク環境の変化」と同様の対策が必要です。

また、ここで注意が必要なのが先ほど少し触れたProxyJump/ProxyCommandの動作です。ProxyJumpやProxyCommandは、途中のサーバーを経由して手元の端末が最終的な接続先へのssh接続を行う機能です。従って、定期的なパケットを接続先に送信するのは手元のPCの役目であり、スリープしている状態だとそれができません。このため、手元からプロキシサーバーまでの経路がVPNで維持されていたとしても、プロキシサーバーから接続先までの経路は(プロキシサーバーがNAT機器の内側にある場合は)NATマッピングの消滅により無効化され、ssh接続は維持できません。

というわけで、VPNで接続したプロキシサーバーを経由する場合は、ProxyCommandやProxyJumpを使うのではなく、プロキシサーバー側でsshを実行して接続先までのトンネルを掘っておく(接続先との定期的な通信はプロキシサーバーに担当させる)のが良いと思います。ネットワークの切り替えは挟むかもしれないが長時間のスリープはしない、という場合はProxyJump/ProxyCommandでも構いません。

原因5: Windowsネットワークアダプタの仕様

完全に解析できてはいませんが、Windowsでは以下のようなときにssh接続が切断されます。

・PCをスリープ状態にしたとき(デスクトップPCのみ?ノートPCでも長時間だとなる?)
・IPを(DHCPではなく)固定割り当てで設定したWi-Fiネットワークから切断されたとき
・(DHCP割り当ての)Wi-Fiネットワークから切断後、異なるSSIDWi-Fiネットワークに接続したとき
Wi-Fi接続から有線接続に切り替えたり、IP割り当てを変更したりして、Windowsに割り当てられるIPが変わったとき

逆に、以下のような場合は(中断されることはあっても)切断されません。

・ノートPCを(短時間?)スリープ状態にしたとき
・PCをスリープ状態にしたときのVPN経由の通信
DHCP割り当てのWi-Fi接続や有線接続から切断後、再び全く同じ(Wi-FiであればSSIDが同じ)ネットワークに接続したとき
Windowsが経由しているスマホなど他のデバイスで接続が切断されたり割り当てられたIPが変更されたりしたとき

Linuxでは、以下のような場合でも(中断されることはあっても)切断されません。

・(デスクトップ含め)PCをスリープ状態にしたとき
・NetworkManagerをrestartした場合
・異なるWi-Fiアクセスポイントに接続したり、割り当てられるIPが変わったりしたとき

このように、Windowsに比べると切れることが少なくなります。特に、接続経路が変わらない場合(2.4GHz→5GHzや、親機→子機or中継器などの切り替え)は引き続き同じ通信を利用できます。

そもそもこのWindowsLinuxの動作の違い自体があまり知られていないようで、WindowsでこのようなLinux的な動作をさせる方法は調べても出てこなかったのですが、VPNのような仮想的な接続ルートを維持するという発想で、ダメ元でHyper-Vの仮想スイッチを作ってみたら、効果がありました。Linuxと同様、同じネットワーク環境内での切り替えやスリープを挟んだ場合でもそのままssh接続が維持されるようになります。

Hyper-V自体あまり使っていないので、スイッチの作成について詳しい説明はできませんが、タイプとしては「外部」を選んで、実体として使う物理NICとしてWi-Fiあるいは有線接続を選ぶだけで、勝手に物理接続が仮想アダプタでラップされた感じになってくれました。仮想マシンを動かすわけではないので負荷・オーバーヘッドはほとんどないのではないかと思います。

なお、Windows 10や11のHomeエディションだとダイアログからHyper-Vの有効化ができませんが、コマンドラインからだとできます(参考:Windows 10や11のHomeエディションでHyper-Vを利用する方法 | 4thsight.xyzWindows10Home でHyper-Vを使う方法を試してみる | sakura86.com)。

ただ、この設定をしても、ネットワークが切り替わってWindowsに異なるIPが割り当てられるとsshはただちに切断されます。一方Linuxでは、1分ごとにネットワークを切り替えながら2つのsshセッションを並行して操作するようなことも可能です。ただし、切り替えのタイミングで文字入力をすると切断されるなど安定はしません。このへんの挙動については詳しくは不明です。

Windowsで同様に2セッション並行をするには、スマホなど他のデバイスを介在させる以外だと、インターネット接続共有(ICS)の機能を使ってHyper-VNIC(固定IP)に対して設定をしてそのIPをsshのbind addressに指定するという方法でも一応できましたが、いずれにしても実用上は無意味でしょう。

原因6: VPNの有効/無効の切り替え

VPNを使えばネットワークの切り替えやスリープなどによる切断を抑制できますが、VPN自体の有効/無効を切り替えるとネットワーク経路やIPが変更されるためssh接続が中断あるいは切断されます。

特に、社内ネットワークへの接続・発信元IPの偽装などの目的で全てのインターネットトラフィックを仮想化するVPNの場合、作業内容によって頻繁に切り替えることもあるかと思いますが、そのたびに無関係な(そのVPNがなくてもアクセスできる)ssh接続まで切断されるのは避けたいところです。

この場合は、ssh接続のために、特定ホストへのアクセスのみを仮想化するVPNを別途構築するのが有効です。具体的な動作はVPNソフトウェアによって異なる可能性があるので確実なことは言えませんが、特定のホストへの経路はインターネットのデフォルト経路より優先されることが多いのではないかと思います。

筆者が試した範囲では、Cisco Anyconnect VPNやTunnelbearの有効/無効を切り替えてもTailscale経由のVPN接続を維持することができました。ただし、Cisco Anyconnect VPNでは「Allow local (LAN) access when using VPN (if configured)」を有効にする必要があります。「if configured」と書いてあるので、接続先サーバーによっては動作しない場合もあるのかもしれません。また、Tailscaleが使用する100.x.y.zのようなIPアドレス(CGNATアドレス)はもう一方のVPNによって処理されてしまい使えないので、--advertise-routesで指定したプライベートIPを使うと良いです。

併用するVPNの仕様によっては、Tailscaleの接続がもう一方のVPN経由になるので、遅延が増える可能性があります(Ciscoもその一例です)。これはSupport specifying which local interfaces to bind to, and support binding to multiple interfaces · Issue #1066 · tailscale/tailscale · GitHubが修正されれば一部解決される可能性があります。

原因7: サーバー側での切断

ここまでは全てクライアント側の設定について書いてきましたが、サーバー側が原因でssh接続が切断されることもあります。ただ、クライアント側に比べると原因は限られます。

まず、sshサーバーは安定したネットワーク環境に設置されて常時稼働しているのが普通なので原因2, 4, 5, 6については考える必要はありません。3に関してもサーバー側では22番ポートが固定的に使われるのでNATテーブルは無関係です。

ただし、1のTCPパケットのタイムアウトには注意が必要です。試していませんが、サーバー側がWindowsである場合は、同様にTcpMaxDataRetransmissionsを増やす必要がありそうです。さらに、pingのようにサーバーから継続的に応答を受け取るようなコマンドを実行している途中にクライアント側で接続が中断された場合、パケットがクライアント側に届かないためにサーバー側が接続を切断してしまう可能性があります。例えば、クライアントが長時間スリープする場合は、クライアント側では通信が停止するのでパケットがタイムアウトすることはありませんが、サーバー側はそのまま通信を続けようとしてタイムアウトし、通信を切断してしまいます。

手元で実験してみました。まず実験用にサーバーのtcp_retries2を5程度に変更して1分以内に接続が切れるようにします。クライアント側では2つの端末を起動してどちらも同じサーバーにssh接続し、片方では何も実行せず、片方はping -n localhostを実行しておきます。その状態でPCを1分ほどスリープさせます。復帰後、何もしていない方の端末ではssh接続が維持されていましたが、pingを実行していた方の端末では切断されました。

すなわち、サーバー側での切断を防ぐには、受け取るべきパケットが残っていない状態にしてからスリープする必要があります。しかし、これはそれなりに難しそうです。単純なシェルであればコマンドが実行されていない状態にすれば十分ですが、VS Code Remote SSHのような複雑なアプリケーションを起動している場合はいつパケットが来るか予測できません。また、単純なシェルでも、ものすごく運が悪ければ、ServerAliveIntervalによるkeep-aliveパケットが送られた直後にスリープされたためにサーバーからの応答のパケットが残ってしまうというようなことも考えられます(つまり、NATが介在しないssh接続であればServerAliveIntervalは無効にした方が安定します)。

サーバー側のTCPパケットのタイムアウトをもっと長くしてもいいですが、多くのユーザーが同時に使用するようなサーバーだと負荷が大きくなりそうです。

ちなみに、クライアント側のServerAliveIntervalに対応する設定としてサーバー側にもClientAliveIntervalという設定がありますが、これはあまり効果がありません。keep-aliveパケットが必要なのはNATエントリを維持するためだけであり、クライアント側が起動しているならServerAliveIntervalでクライアント側から送れば十分だからです。クライアントがスリープしているときは通信が成立しないので、サーバーからkeep-aliveパケットを送ってもNATテーブルの維持には寄与しないのではないかと思います(NATテーブルの細かい動作があまりよくわかっていません)。むしろ、不必要に疎通確認をすることでTCPパケットのタイムアウトssh自体のタイムアウト(ClientAliveCountMax)に達する可能性もあります。従ってClientAliveIntervalはむしろ無効にしたほうが接続を維持しやすいことが多いでしょう。

これ以外にも、サーバー側の独自の設定によって接続が切断される可能性もあります。例えばGCPのCompute EngineのVPCネットワークでは10分間通信がないTCP接続が無効化されるようです(Compute Engine を使用する場合の一般的なヒント  |  Compute Engine ドキュメント  |  Google Cloud)。実際試してみると、複数のサーバーに接続した状態で40分ほどスリープするとGCPだけが切断されていました。クライアントが長時間スリープしても接続が切れないようにするには、プロキシサーバーからトンネルを掘っておくしかなさそうです。

原因8: conhost.exeのバグ

これはssh自体とは無関係で、しかもWindowsに限定した話ですが、Windowsのconhost.exeは文字選択中にプログラムの出力がブロックされるという仕様があります(参考: ruby on rails - how can I stop my server from freezing when powershell is in 'select' mode? - Stack Overflow)。また、経験上、文字を選択していなくても長時間(数十分~数時間)の放置で似た状態になることがあります。これが原因でパケットが通らず通信が切断される可能性もあるかもしれません。Windows Terminalやminttyなど、このような問題が発生しない別の端末を使いましょう。ssh以外でも気を付けてください。

その他の対策: tmuxとかscreenとかautosshとか

ここまで、ssh接続を維持する方法について解説してきましたが、「ssh接続が完全に切断されたとしても影響を最小限にする」ような方法もあります。

例えばtmuxscreenのような仮想端末を用いると、ssh接続とは独立に端末セッションを管理できるため、切断されてから接続し直す場合でも元の作業状況を引き継ぐことができます。

また、対話セッションの維持はできませんが、autosshは切断が起こると勝手に再接続してくれるので、sshトンネルを恒久的に設置しておきたい場合には有用です。

その他

Keep ssh connection alive if internet briefly disconnects - Unix & Linux Stack Exchange Linuxのipコマンドでtuntapとかいう見慣れないキーワードを使っています。なんか関係ありそうですが、詳細不明。

余談: WindowsでHTTPプロキシ経由でSSHしてみた

一応試したのでメモしておきます。

まずこれはWindows関係なくHTTPプロキシ(squid)側の設定なのですが、HTTP Proxy経由でsshを使う | にーまるろく あーるしー どっと ねっとこのようにsquid側で22番ポートを有効にする必要があったようです。

また、Windowsでは、プロキシに使うconnect(あるいはconnect_proxy)コマンドがパスの通った場所に存在しないことが多く、Linux用のconfigのProxyCommandの内容をそのままコピーしてきても動きません。

そこで、Git for Windowsに付属してくるconnect.exeをフルパスで指定してやると動くようになります(参考: Proxy下のWindows10でOpenSSHする #Windows10 - QiitaWindows SOCKS Proxy SSH環境で Git Bash と VSCode Remote Development の ~/.ssh/config を共通にする #VSCode - QiitaSSHを駆使して数々の試練(プロキシ、踏み台)を乗り越える話 #SSH - Qiitaなど)。

まとめ

現象の発生に比較的長時間かかる場合も多いことから「なんか気づいたらssh切れててウザい」くらいに認識されていることが多いsshの切断ですが、意外と色々なところに原因があり、それぞれ違った対策が必要ということがわかります。

まとめると、TcpMaxDataRetransmissionsは(切断を防ぐためなら)上げて損なし、ServerAliveIntervalはNAT環境なら設定推奨、複数のネットワークを渡り歩くならVPN必須、安定した環境ならHyper-VWindowsLinuxに変えてごまかす、という感じになります。