
Node.jsのssh2で踏み台サーバー経由のダブルホップSSHトンネルを構築する(自動再接続付き)
はじめに
Kubernetesクラスターに到達するために、踏み台サーバー(Jump Server)を経由する必要がある環境は多いと思います。手動でSSHする場合は ssh -J jump-server remote-host で済みますが、Node.jsの自動化システムからプログラム的に接続するには ssh2 ライブラリで2段階のトンネルを構築する必要があります。
さらに、長時間バッチ処理(数時間に及ぶこともある)の途中でSSHトンネルが切れると、途中から再開できずに最初からやり直しになります。
本記事では、ssh2 を使ったダブルホップSSHトンネルの実装と、長時間運用に耐える切断検知・自動再接続の仕組みを紹介します。
前提・環境
- Node.js + TypeScript
ssh2ライブラリ(SSH クライアント)- 踏み台サーバー → リモートサーバーの2ホップ構成
アーキテクチャ

jumpTunnelとremoteTunnelは依存関係にあります。jumpTunnelが切れるとremoteTunnelも使えなくなりますが、remoteTunnelだけが切れるケースもあります。
なぜダブルホップが必要なのか
リモートサーバー(K8sクラスター等)は、セキュリティ上の理由で外部ネットワークから直接到達できません。ファイアウォールやネットワークポリシーにより、SSH接続できるのは踏み台サーバーだけです。踏み台サーバーだけがリモートサーバーへの経路を持っています。

手動なら ssh -J jump-server remote-host で1コマンドですが、Node.jsの ssh2 ライブラリにはこの -J 相当の便利APIがありません。2つのSSH接続を自分で構築し、繋ぎ合わせる必要があります。それが forwardOut + sock: stream の組み合わせです。
SSHトンネルが切れる原因
長時間の運用で経験した切断パターンは以下です。
- ネットワーク不安定: WiFiの切断、ISPの瞬断
- 踏み台サーバー側のタイムアウト: idle接続のキルポリシー
- ゼロトラストプロキシのセッション期限切れ
- OSのスリープ/復帰: ノートPCの蓋を閉じた時
実装
1. 踏み台サーバーへの接続
まず、踏み台サーバーにSSH接続します。
import { Client, ConnectConfig } from "ssh2";
class Remote {
private jumpTunnel?: Client;
private remoteTunnel?: Client;
private async connectJumpServer(): Promise<void> {
return new Promise((resolve, reject) => {
const jumpTunnel = new Client();
jumpTunnel
.on("ready", () => {
this.jumpTunnel = jumpTunnel;
// 切断時のハンドラを登録
jumpTunnel.on("error", (err) => {
console.error(`JumpTunnel Error: ${err.message}`);
this.handleJumpTunnelDisconnection();
});
jumpTunnel.on("close", () => {
console.warn("JumpTunnel Closed");
this.handleJumpTunnelDisconnection();
});
resolve();
})
.on("error", (err) => reject(err))
.connect({
host: "127.0.0.1",
port: 2222, // Pomerium等で転送されたポート
username: "your-user",
password: "your-password",
readyTimeout: 60000,
keepaliveInterval: 30000, // 30秒ごとにキープアライブ
keepaliveCountMax: 3, // 3回失敗で切断とみなす
});
});
}
}
2. リモートサーバーへの転送(ダブルホップの核心)
踏み台サーバーの forwardOut メソッドで、リモートサーバーへのストリームを作成します。そのストリームを2つ目の SSH Client の sock オプションに渡すのがポイントです。
public async forwardToRemote(): Promise<void> {
if (!this.jumpTunnel) await this.initJumpTunnel();
return new Promise((resolve, reject) => {
if (!this.jumpTunnel) throw new Error("Jump tunnel not initialized");
this.jumpTunnel.forwardOut(
"127.0.0.1", // ソースアドレス(形式上の指定)
8000, // ソースポート(形式上の指定)
"REMOTE_HOST", // 転送先ホスト
22, // 転送先ポート(SSH)
(err, stream) => {
if (err) return reject(err);
// stream を sock として新しい SSH Client を作成
const remoteTunnel = new Client();
remoteTunnel
.on("ready", () => {
this.remoteTunnel = remoteTunnel;
// 切断時のハンドラを登録
remoteTunnel.on("error", (err) => {
console.error(`RemoteTunnel Error: ${err.message}`);
this.handleRemoteTunnelDisconnection();
});
remoteTunnel.on("close", () => {
console.warn("RemoteTunnel Closed");
this.handleRemoteTunnelDisconnection();
});
resolve();
})
.on("error", (err) => {
console.error(`RemoteTunnel connect error: ${err}`);
this.handleRemoteTunnelDisconnection();
})
.connect({
sock: stream, // ← ここがダブルホップの核心
username: "your-user",
password: "your-password",
readyTimeout: 60000,
} as ConnectConfig);
}
);
});
}
sock: stream を指定すると、ssh2 は通常のTCPソケットの代わりにこのストリームを使ってSSHハンドシェイクを行います。ストリームの中身は踏み台サーバー経由でリモートサーバーの22番ポートに到達しています。
なぜ sock: stream が必要なのか
forwardOut を呼んでいるのだから、ssh2 は「踏み台サーバー経由でリモートに接続したい」という意図を理解してくれてもよさそうです。しかし実際には、forwardOut() と connect() は互いを知らない独立したAPIです。
forwardOut()は汎用ポートフォワーディングです。「リモートホストの指定ポートへの双方向バイトパイプをくれ」と踏み台サーバーに依頼します。SSH接続に限らず、DB(3306)やHTTP(80)など何にでも使えます。返すのは生のバイト列であり、SSHセッションではありません。connect()はSSHハンドシェイクを開始します。デフォルトでは自分でTCPソケットを開いてhost:portに接続しますが、sockオプションを指定すると既存のストリーム上でプロトコルを実行します。
ライブラリが connectVia(jumpClient, remoteHost, 22, config) のような便利メソッドを提供する設計もあり得ましたが、ssh2 は低レベルのビルディングブロックを提供する設計を選びました。sock オプションは「常にTCPを開く」のではなく、トンネル、WebSocket、既存の接続など非標準トランスポート上でSSHを実行するためのエスケープハッチです。

ストリーム以外の選択肢はあるか
sock: stream 以外にも方法は存在しますが、どれもトレードオフがあります。
ローカルポートフォワーディング: forwardOut で取得したストリームをローカルのTCPサーバーにパイプし、2つ目のSSHクライアントを localhost:port に接続する方法です。動作しますが、不要なループバックTCPの往復が発生します。
// ストリーム → TCP → ソケット → SSH という無駄な経路
const server = net.createServer((localSock) => {
jumpTunnel.forwardOut("127.0.0.1", 0, "REMOTE_HOST", 22, (err, stream) => {
localSock.pipe(stream).pipe(localSock);
});
});
server.listen(0);
remoteTunnel.connect({ host: "127.0.0.1", port: server.address().port, ... });

exec + netcat: 踏み台サーバー上で nc REMOTE_HOST 22 を実行し、そのストリームを sock に渡す方法です。OpenSSHの ProxyCommand の仕組みに近いですが、踏み台サーバーに nc がインストールされている必要があります。そして結局 sock: stream は使います。
// 結局 sock: stream は必要
jumpTunnel.exec("nc REMOTE_HOST 22", (err, stream) => {
remoteTunnel.connect({ sock: stream, ... });
});

結論: forwardOut() + sock: stream が最もクリーンな方法です。余計なオーバーヘッドも外部依存もありません。ストリームの取得方法が違うだけで、「生のバイトパイプ上でSSHプロトコルを実行する」という本質は変わりません。
なお、ここでの stream はJSONなどの「出力フォーマット」ではありません。SSHプロトコルはバイナリです。リモートサーバーのポート22は SSH-2.0-OpenSSH_8.9\r\n のようなバイト列に続いて暗号化された鍵交換パケットを送ってきます。sock が期待するのはNode.jsの Duplex ストリーム(TCPソケットのように振る舞う双方向バイトパイプ)であり、ssh2 はその上でSSHの暗号化・認証・チャネル多重化を行います。
3. リモートコマンドの実行
トンネルが確立されたら、exec() でコマンドを実行します。
public async execRemote(
cmd: string,
debug: boolean = false
): Promise<string> {
// 遅延初期化: トンネルがなければ自動的に接続
if (!this.remoteTunnel) await this.initRemoteTunnel();
return new Promise((resolve, reject) => {
if (!this.remoteTunnel) throw new Error("Remote tunnel not initialized");
this.remoteTunnel.exec(cmd, (err, stream) => {
if (err) return reject(err);
let data = "";
let meta = "";
stream.on("data", (chunk: Buffer) => (data += chunk.toString()));
stream.stderr.on("data", (chunk: Buffer) => (meta += chunk.toString()));
stream.on("end", () => {
if (debug) console.log(meta);
resolve(data);
});
});
});
}
// Jumpサーバーでのコマンド実行も同じパターン
public async execJump(cmd: string): Promise<string> {
if (!this.jumpTunnel) await this.initJumpTunnel();
return new Promise((resolve, reject) => {
this.jumpTunnel!.exec(cmd, (err, stream) => {
if (err) return reject(err);
let data = "";
stream.on("data", (chunk: Buffer) => (data += chunk.toString()));
stream.on("end", () => resolve(data));
});
});
}
遅延初期化パターンに注目してください。if (!this.remoteTunnel) await this.initRemoteTunnel() により、初回の execRemote() 呼び出し時にトンネルが自動的に構築されます。呼び出し側はトンネルの存在を意識する必要がありません。
4. SFTP操作も同じパターン
ファイル転送(SFTP)操作もトンネル経由で行うため、同じ遅延初期化パターンを適用しています。
public async getRemoteSftp() {
if (!this.remoteSFTP) await this.initRemoteSFTP();
if (!this.remoteSFTP) throw new Error();
return this.remoteSFTP;
}
private async initRemoteSFTP() {
if (!this.remoteTunnel) await this.initRemoteTunnel();
if (!this.remoteTunnel) throw new Error();
this.remoteSFTP = new SFTP(this.remoteTunnel);
}
SFTP操作が不要なタスクではトンネル確立コストが発生しません。
5. 自動再接続
長時間稼働するシステムでは、ネットワーク障害やサーバーリスタートによりトンネルが切断されます。切断時に状態をクリアし、次回の execRemote() で自動的に再接続する仕組みを入れます。
private handleJumpTunnelDisconnection() {
console.warn("Attempting to re-establish JumpTunnel...");
this.jumpTunnel = undefined;
this.initJumpTunnel().catch((err) => {
console.error(`Failed to re-establish JumpTunnel: ${err}`);
});
}
private handleRemoteTunnelDisconnection() {
console.warn("Attempting to re-establish RemoteTunnel...");
this.remoteTunnel = undefined;
this.initRemoteTunnel().catch((err) => {
console.error(`Failed to re-establish RemoteTunnel: ${err}`);
});
}
再接続の流れ:

6. 初期化の連鎖
各層の初期化は依存関係があるため、連鎖的に呼び出されます。
private async initJumpTunnel() {
await this.connectJumpServer();
}
private async initRemoteTunnel() {
await this.forwardToRemote(); // 内部で initJumpTunnel() を呼ぶ
}
全体のフロー:
execRemote() / getRemoteSftp() 呼び出し
└─ remoteTunnel? → initRemoteTunnel()
└─ jumpTunnel? → initJumpTunnel()
└─ connectJumpServer()
└─ forwardToRemote()
どの層が切断されても正しい順序で再構築されます。
運用で遭遇した問題
readyTimeoutの設定
デフォルトの readyTimeout は短すぎることがあります。特にPomerium経由の場合、認証処理に時間がかかるため 60000(60秒)を設定しています。
ストリームのバッファリング
exec() のレスポンスは複数の data イベントに分割されることがあります。stream.on("end") で全チャンクが到着してから resolve するのが重要です。途中で resolve すると、レスポンスが途切れたJSONを受け取りパースエラーになります。
踏み台のKeepAlive
踏み台サーバーがアイドル接続をタイムアウトで切断する場合、ssh2の keepaliveInterval オプションを設定します。
.connect({
// ...
keepaliveInterval: 30000, // 30秒ごとにキープアライブ
keepaliveCountMax: 3, // 3回失敗で切断とみなす
});
まとめ
この実装は4つの要素で成り立っています。
ダブルホップが必要な理由: リモートサーバーに直接到達できないネットワーク構成では、踏み台サーバー経由の2段階接続が必須。ssh2には -J 相当の便利APIがないため、自分でビルディングブロックを組み合わせる。
sock: stream で多段SSH接続: forwardOut() と connect() は互いを知らない独立したAPIであり、forwardOut が返す生バイトストリームを connect({ sock: stream }) に渡すことで初めてSSH-in-SSHが実現する。ローカルポートフォワーディングやnetcat経由など代替手段もあるが、forwardOut + sock が最もオーバーヘッドが少ない。
遅延初期化: 各メソッド先頭で if (!tunnel) await init()。トンネルが存在しなければ自動接続。SFTP不要なタスクでは接続コストゼロ。
切断検知と依存チェーン再構築: close/error イベントで状態をクリアし、次回アクセス時に自動再接続。依存チェーン(remote → jump)で多段トンネル全体を自動再構築。
ssh2のチュートリアルでは接続方法は解説されていますが、なぜ sock が必要なのか、代替手段はあるのか、長時間運用での切断対策はどうするのかといった点は扱われていません。自動化ツールをSSHトンネル経由で動かす場合、この遅延初期化 + 切断リセットのパターンは応用が利くと思います。






