踏み台サーバー経由の2ホップデプロイをNode.js(ssh2)で完全自動化してみた

踏み台サーバー経由の2ホップデプロイをNode.js(ssh2)で完全自動化してみた

インターネット接続なし・踏み台経由必須という制約のある閉域ネットワーク環境へのデプロイを、Node.jsのssh2ライブラリで完全自動化しました。SFTP/SCPによる2ホップ転送とbash -sを活用したリモートスクリプト実行の実装を紹介します。
2026.06.20

はじめに

閉域ネットワーク上のサーバーにWebアプリをデプロイする必要がありました。ただし、そのサーバーには以下の制約があります。

  • インターネット接続なしgit clonedocker pull が使えない
  • 踏み台サーバー経由でしかSSH接続できない(2ホップ必須)
  • 踏み台にはSFTPでアップロード可能だが、踏み台→デプロイ先のSSH鍵は踏み台上にしかない(ローカルから scp -J が使えない)

最初は手作業でやっていましたが、コマンドのタイポや手順抜けが頻発したため、パック→転送→展開→起動を1コマンドで完結するスクリプトをNode.jsのssh2ライブラリで実装しました。

この記事では、デプロイオーケストレーター(deploy.mjs)、サーバー側スクリプト(deploy.sh)、リモートログストリーミング(logs.mjs)の3つを紹介します。

前提・環境

項目
ローカルOS macOS
Node.js 24 LTS
ssh2 1.17.0
デプロイ先 Linux(Docker, Docker Compose インストール済み)
コンテナ構成 フロントエンド(Next.js) + バックエンド(FastAPI)

ネットワーク構成

ssh2-bastion-2hop-deploy-automation-network

ローカルからデプロイ先に直接接続する手段はなく、すべて踏み台を経由する必要があります。

全体のデプロイフロー

ssh2-bastion-2hop-deploy-automation-flow

実装

deploy.mjs — ローカル側オーケストレーター

package.json"deploy": "node scripts/deploy.mjs" として登録し、pnpm deploy で呼び出します。

SSH認証の解決

まずSSH認証方法を自動解決します。SSH_AUTH_SOCK(ssh-agent)があればそれを使い、なければ ~/.ssh のキーファイルを探します。

function resolveAuth() {
  const agentSocket = process.env.SSH_AUTH_SOCK;
  if (agentSocket) {
    return { agent: agentSocket };
  }
  for (const name of ['id_ed25519', 'id_rsa', 'id_ecdsa']) {
    const keyPath = path.join(os.homedir(), '.ssh', name);
    if (fs.existsSync(keyPath)) {
      return { privateKey: fs.readFileSync(keyPath) };
    }
  }
  throw new Error('No SSH auth available');
}

Step 1: パック

.gitnode_modules.next.venv.env(秘密情報)、.ignore を除外してtar.gzを作成します。

await execFileAsync('tar', [
  '-czf', ARCHIVE_PATH,
  `--exclude=${PROJECT_NAME}/.git`,
  `--exclude=${PROJECT_NAME}/node_modules`,
  `--exclude=${PROJECT_NAME}/.next`,
  `--exclude=${PROJECT_NAME}/backend/.venv`,
  `--exclude=${PROJECT_NAME}/backend/data`,
  `--exclude=${PROJECT_NAME}/.env`,
  `--exclude=${PROJECT_NAME}/.ignore`,
  PROJECT_NAME,
], {
  cwd: PARENT_DIR,
  env: { ...process.env, COPYFILE_DISABLE: '1' },  // macOSの._ファイル抑止
});

COPYFILE_DISABLE: '1' はmacOS固有で、._ で始まるリソースフォークファイルがアーカイブに混入するのを防ぎます。

Step 2: ローカル → 踏み台(SFTP)

ssh2のsftpサブシステムを使い、進捗表示付きでアップロードします。

function sftpPut(sftp, localPath, remotePath) {
  return new Promise((resolve, reject) => {
    const total = fs.statSync(localPath).size;
    let lastPct = 0;
    sftp.fastPut(localPath, remotePath, {
      step: (transferred) => {
        const pct = Math.floor((transferred / total) * 100);
        if (pct >= lastPct + 10) {
          process.stdout.write(
            `\r  ${pct}% (${(transferred / 1e6).toFixed(1)} / ${(total / 1e6).toFixed(1)} MB)`
          );
          lastPct = pct;
        }
      },
    }, (err) => {
      process.stdout.write('\r  100%\n');
      if (err) reject(err); else resolve();
    });
  });
}

fastPut はファイル全体を効率的に転送するssh2のメソッドで、step コールバックで転送量を監視できます。

Step 3: 踏み台 → デプロイ先(SCP)

踏み台上で scp コマンドを実行します。ローカルから scp -J(ProxyJump)を使えない理由は、SSH鍵が踏み台上にしかないためです。

await bastionExec(
  bastion,
  `scp -o StrictHostKeyChecking=no ${bastionDest} ${DEPLOY_USER}@${DEPLOY_HOST}:`
);

ここで bastionExec は、踏み台SSHセッション上でコマンドを実行し、stdout/stderrをリアルタイムでローカルに中継するヘルパーです。

function bastionExec(conn, cmd, deployScript) {
  return new Promise((resolve, reject) => {
    conn.exec(cmd, (err, stream) => {
      if (err) return reject(err);
      if (deployScript) {
        stream.write(deployScript);
        stream.end();
      }
      stream.on('data', (d) => process.stdout.write(d));
      stream.stderr.on('data', (d) => process.stderr.write(d));
      stream.on('close', (code) => {
        if (code === 0) resolve();
        else reject(new Error(`Bastion exec exited with code ${code}: ${cmd}`));
      });
    });
  });
}

Step 4: リモートデプロイ(bashスクリプトをstdinにパイプ)

ここが一番面白いポイントです。deploy.sh をローカルで読み込み、踏み台経由でデプロイ先の bash -s の標準入力にパイプします。

const deployScript = fs.readFileSync(DEPLOY_SH);
const bashCmd = DEPLOY_ARGS ? `bash -s -- ${DEPLOY_ARGS}` : 'bash -s';

await bastionExec(
  bastion,
  `ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} ${bashCmd}`,
  deployScript,
);

bash -s はスクリプトを標準入力から読み取って実行するコマンドです。これにより、デプロイスクリプトをリモートサーバーに事前配置する必要がなく、常にローカルの最新版が実行されます。

デプロイログの自動保存

全出力をタイムスタンプ付きファイルにティーイングしています。

const LOG_DIR = path.resolve(SCRIPT_DIR, '..', '.ignore', 'deploy-logs');
fs.mkdirSync(LOG_DIR, { recursive: true });
const logFile = path.join(LOG_DIR, `${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
const logStream = fs.createWriteStream(logFile, { flags: 'a' });

const origStdoutWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk, ...args) => {
  logStream.write(chunk);
  return origStdoutWrite(chunk, ...args);
};

process.stdout.write をラップすることで、通常のコンソール出力を維持しつつ、ファイルにも自動保存しています。デプロイ失敗時の原因調査に役立ちます。

deploy.sh — サーバー側スクリプト

deploy.shbash -s でリモート実行されるスクリプトです。--init(初回)とフラグなし(更新)で動作を切り替えます。

.envのバックアップと復元

更新時に既存の .env を退避し、新しいソースコード展開後に復元する仕組みです。

# アップデート時: コンテナ停止 → .env退避
if [[ -f "${DEPLOY_DIR}/docker-compose.yml" ]]; then
  cd "$DEPLOY_DIR" && docker compose down
  cd "$HOME"
  if [[ -f "${DEPLOY_DIR}/.env" ]]; then
    cp "${DEPLOY_DIR}/.env" "$HOME/.env.my-app.bak"
  fi
fi

# 旧ファイル削除 → アーカイブ展開
find "${DEPLOY_DIR}" -mindepth 1 -delete
tar -xzf "$ARCHIVE" --warning=no-unknown-keyword
cp -a "${SOURCE_DIR}/." "$DEPLOY_DIR/"

# .env復元
if [[ -f "$HOME/.env.my-app.bak" ]]; then
  cp "$HOME/.env.my-app.bak" "${DEPLOY_DIR}/.env"
fi

.env はアーカイブに含めていない(秘密情報のため)ので、サーバー上で一度設定すれば以降の更新で自動復元されます。

初回デプロイ時の.envガイド

初回(--init)で .env がなければ、設定手順を表示して停止します。

if [[ "$INIT" == true ]]; then
  echo "----------------------------------------------"
  echo " .env が未設定です。次の手順で作成してください:"
  echo ""
  echo "   cp ${DEPLOY_DIR}/.env.example ${DEPLOY_DIR}/.env"
  echo "   vi ${DEPLOY_DIR}/.env"
  echo ""
  echo " 設定後、再度このスクリプトを実行してください:"
  echo "   pnpm deploy --init"
  echo "----------------------------------------------"
  exit 1
fi

ヘルスチェック

docker compose up -d --build
sleep 3
curl -sf http://localhost:40002/v1/healthz && echo "backend OK" \
  || echo "WARNING: backend health check failed"

-s(サイレント)-f(HTTPエラー時に失敗)フラグを使い、ヘルスチェック失敗はWARNING表示のみでスクリプト自体は正常終了します。コンテナ起動直後はまだ準備中の場合があるため、3秒待ってからチェックしています。

Docker Compose構成

本番用と開発用のDockerfileを分離し、docker-compose.override.yml でポートをリマップしています。

# docker-compose.yml(ベース)
services:
  backend:
    build:
      context: .
      dockerfile: Dockerfile.backend
    env_file: .env
    restart: unless-stopped

  frontend:
    build:
      context: .
      dockerfile: Dockerfile.frontend
    env_file: .env
    depends_on:
      - backend
    restart: unless-stopped
# docker-compose.override.yml(環境固有のポートマッピング)
services:
  frontend:
    ports:
      - "40001:3000"
  backend:
    ports:
      - "40002:8765"

docker-compose.override.yml はDocker Composeが自動的にマージするファイルです。ベースの docker-compose.yml にはポート定義を含めず、環境ごとのオーバーライドで設定することで、同じソースコードを異なるポート体系にデプロイできます。

今回のデプロイ先EC2はコスト削減のため稼働時間が8:00〜22:00にスケジュールされており、毎朝自動起動・毎晩自動停止します。当初は restart ポリシーを設定していなかったため、朝EC2が起動するたびにSSHでログインして手動で docker compose up -d を実行する必要がありました。

restart: unless-stopped を設定することで、Dockerデーモンの起動時(= EC2の起動時)にコンテナが自動的に再開されるようになり、毎朝の手作業が不要になりました。

restart: unless-stopped

always ではなく unless-stopped を選んだのは、docker compose down で意図的に停止した場合はそのまま停止させたいためです。デプロイ中にコンテナを停止→再展開→再起動するフローと干渉しません。

logs.mjs — リモートログストリーミング

デプロイ後のデバッグ用に、リモートの docker compose logs をローカルにストリーミングするスクリプトも作りました。

const FOLLOW = process.argv.includes('--follow');
const TAIL = process.argv.find((a) => a.startsWith('--tail='))?.split('=')[1] ?? '200';

// サービス名のバリデーション(コマンドインジェクション防止)
const rawService = process.argv.find(
  (a) => !a.startsWith('-') && a !== process.argv[0] && a !== process.argv[1]
) ?? '';
if (rawService && !/^[a-zA-Z0-9_-]+$/.test(rawService)) {
  throw new Error(`Invalid service name: ${rawService}`);
}

const remoteCmd = `ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} \
  'cd ${DEPLOY_DIR} && docker compose logs --tail=${TAIL}${followFlag} ${service}'`;

使い方:

pnpm logs                      # 直近200行を表示
pnpm logs -- --follow          # リアルタイムストリーミング
pnpm logs -- --follow backend  # backendコンテナのみ

デプロイ先に直接SSHする手間なく、ローカルから1コマンドでログを確認できます。

実行結果

$ pnpm deploy

=== pack ===
Packing project -> ../my-app.tar.gz
created: ../my-app.tar.gz (12.3 MB)

=== ship: step 1 - local -> bastion ===
Bastion connected.
Uploading to bastion: /home/user/my-app.tar.gz
  100%

=== ship: step 2 - bastion -> target ===
(~12 MB, may take a minute...)
done: my-app.tar.gz -> user@10.xxx.x.xx:~/

=== remote deploy (bastion -> target) ===
=== extract ===
stopping containers...
.env backed up
extracted → /var/www/my-app
.env restored
=== .env ===
.env OK
=== deploy ===
Building backend...
Building frontend...
...
=== done ===
backend OK

log saved: .ignore/deploy-logs/2026-06-19T10-30-00-000Z.log

パック→2ホップ転送→展開→Docker Compose起動→ヘルスチェックまで、約3分で完了します。

工夫したポイント

ポイント 理由
bash -s でスクリプトをstdinパイプ リモートにスクリプトを配置不要。常にローカル最新版が実行される
.env バックアップ・復元 秘密情報はアーカイブに含めず、更新時も手動再設定不要
--init / フラグなしの分岐 初回と更新で安全に動作を切り替え
SFTP進捗表示 大きなアーカイブでもハング判定が容易
デプロイログ自動保存 障害時の原因調査が迅速化
COPYFILE_DISABLE: '1' macOS固有の._ファイル混入防止
restart: unless-stopped EC2スケジュール起動時にコンテナ自動再開。毎朝の手動起動が不要に

まとめ

閉域ネットワーク+踏み台経由という制約下でも、ssh2ライブラリを使えばNode.jsスクリプト1本で2ホップのデプロイパイプラインを構築できます。

特に bash -s へのスクリプトパイプは汎用的なテクニックで、リモートサーバーにスクリプトファイルを配置・管理する手間を省けます。Docker Composeの override.yml と組み合わせることで、同一ソースコードを異なる環境ルールにそのまま適用できる柔軟性も得られました。

閉域環境へのデプロイで困っている方の参考になれば幸いです。

この記事をシェアする

関連記事