Node.jsとShellで自由度の高いサーバ間のファイル転送を実現する


以前[こちら](https://qiita.com/mjusui/items/bce4566ffdb54ba7039a)の記事で、AnsibleやChefまたはServerspec変わるツールをNode.jsで開発した話を紹介いたしました

それから長い間、記事を投稿していませんでしたが、ついに[v1.0](https://gitlab.com/mjusui/submarine/tree/v1.0)をリリースしたことをご報告いたします(個人的には、かなりの充実感)

Submarine v1.0 - https://gitlab.com/mjusui/submarine/tree/v1.0

* v0.9xを開発してから、社内で導入してみて使用感を確認し、他のエンジニアからのフィードバックをいただき、確かな手応えを感じました
* v1.0には、上記の検証を踏まえて微妙な修正を加えつつ、新たにファイル転送の機能を強化しております

今回は[v1.0](https://gitlab.com/mjusui/submarine/tree/v1.0)の目玉機能であるファイル転送について、ご紹介します

# ファイルを転送するのは意外と複雑

インフラの構築/運用をする上で、サーバAからサーバBにファイルをコピーしたいということはよくありますが、その際、考えなければならないことは実は意外と多く**神経を使うものです**

1. 転送元のサーバのIPアドレス
2. 転送元ファイルのパス
3. 転送元ファイルの権限
4. 転送先サーバのIPアドレス
5. 転送先ファイルのパス
6. 転送先ファイルの権限
7. 転送のプロトコルやコマンド(rsync, scp, ftp, httpなど)
8. どこからコマンドを実行するか(転送元サーバ、転送先サーバ、踏み台サーバ)
9. エラーハンドリング(sshのセッション切れや、スクリプト実行の場合は失敗を検知できる仕組みを実装するなど)
10. 実行ログの出力
11. ファイル数(場合によっては1コマンドでは対応できない場合も)
12. データサイズ(転送先や経由するサーバのディスクに空きはあるか?)
13. メモリ容量
14. CPU性能や並列化の可否
15. I/O性能
16. ネットワーク帯域

プロダクト環境でネットワーク越しにファイルをコピーする場合など、丁寧に作業しようとすると、上記のようなことを一つひとつ検討し、最終的に実行するコマンドを作り込んでいかなければなりません

また定期実行でスクリプト化する場合などはエラーハントリングやログの保存など、異常に気づき対応するための機構を追加で実装しなければなりません

v1.0では、上記の1から11に関して、かなり**プログラマブルに記述**できるようにしました (12から16はマシン性能やサービスの負荷状況にもよるので、またの機会に)

# Ansibleにおけるファイル転送

v1.0の機能を紹介する前に、簡単にAnsibleでのファイル転送には、どういった機能があるかを紹介しておきます

## ファイル操作のモジュール

Ansibleはファイル操作に関するモジュールが多くあります

[Document](https://docs.ansible.com/ansible/latest/modules/list_of_files_modules.html)で確認できるファイル操作に関するモジュールの一覧は20個程度

冪等性を実現するためにAnsibleでは、冪等でない方法(Linuxのcpコマンドなど)で実現できることも、あえて冪等性が保証されたモジュールでラップして提供されています

これは、ありがたいことかもしれませんが、私個人としてはファイルをコピーしたいだけなのにAnsible独自のモジュールの仕様(バージョンが頻繁に上がり、そのたび変更される可能性がある)を調べたり憶えたりすることを、煩わしく感じたりします

また似たような機能で、微妙に挙動が異なるだけのモジュールが乱立している感も否めません

Ansibleの開発者にとってもメンテナンス範囲が増えるので、大変なのではないでしょうか?

# Submarineにおけるファイル転送

Submarineでは、以下のような書き方で、ファイルを異なるサーバ感で転送できるようにしました

ポイントは

* 転送元のIPを複数同時に指定できる
* 転送元のパスを複数同時に指定できる
* ローカルのパスも指定できる
* `class` を `new` するときに上記の全ファイルが一気に、ターゲットのサーバに転送される
* 転送先のパスは指定せず `new` するごとに一意な暫定パス `/tmp/submarine-<uuid>/` にファイルを生成してくれる
* 転送は全てscpで実行される
* リモートtoリモートの転送ができる(その場合、ローカルの `/tmp/submarine-<uuid>` を経由する)

ということです

```Transfer.js
const Submarine=require('Submarine');

const TransferFiles=class extends Submarine {
  fetch(){
    return [{
      a: `${__dirname}/files/a.txt`,
    }, {
      conn: 'ssh',
      host: 'server2',
      files: {
        b: `${__dirname}/files/b.txt`,
        c: `${__dirname}/files/dir`,
      },
   }];
  }

  query(files1, files2){
    return {
      a: String.raw`
        cat ${files1.a}
      `,

      b: String.raw`
        cat ${files2.b}
      `,

      c: String.raw`
        cat ${files2.c}/c.txt
      `,

      fileA: String.raw`,
        echo ${files1.a}
      `,

      fileB: String.raw`
        echo ${files2.b}
      `,

      fileC: String.raw`
        echo ${files2.c}/c.txt
      `,
    };
  }

  test(stats, files1, files2){
    return {
      a: stats.a === 'a', // true
      b: stats.b === 'b', // true
      c: stats.c === 'c', // true
    };
  }

  command(tests, files1, files2){
    return String.raw`
      cat ${files1.a} ${files2.b} ${files2.c}/c.txt
    `;
  }

}


const transfer=new TransferFiles({
  conn: 'ssh',
  host: 'server1',
});
    

Promise.all([
  transfer.current(),
  transfer.conclude(),
]).then(console.log).finally(
  none => transfer.close()
);
```


(1) `fetch` という関数で、転送元のIPとファイルパスの配列を返すよう、実装します

```ローカルファイルの場合.js
{
  a: 'path/to/a',
  b: 'path/to/b',
}
```

```リモートサーバのファイルの場合.js
{
  conn: 'ssh',
  host: 'hostname or ip',
  files: {
    a: 'path/to/a',
    b: 'path/to/b',
  },
}
```

すると `class` が `new` されたときに 転送先の `/tmp/submarine-<uuid>/` 配下にファイルが置かれます(パスはSubmarineが勝手に生成してくれます)

(2) `query`, `test`, `command` の引数として `/tmp/submarine-<uuid>/` 配下に転送されてきたファイルのパス一覧が渡されます

```filesの中身.js
{
  a: '/tmp/submarine-<uuid>/temporally/a',
  b: '/tmp/submarine-<uuid>/temporally/b',
}
```

(3) あとは一時ファイルを適当なパスに`mv`するなり`chown`するなり自由にできます

(4) `close()` を実行すると `/tmp/submarine-<uuid>` の一時ファイルは全て削除されます


# 想定される利用方法

* 1台のサーバを複数台にクローンする

試しにターミナルからコマンド手打ちでサーバ1台を構築してみて、動作確認が取れたら、そのサーバのconfファイルまたはイメージファイルをもとに複数台にクローンします。こうすることで、サーバ複数台を構築する場合も、1台目をターミナルから構築したのと、ほとんど変わらない学習コストで構築できます。これをAnsibleでやろうとすると、ターミナルで構築する手順を、Ansibleの記法に落とし込む作業が発生してしまいます

* 複数サーバのファイルにアクセスするバッチの前処理

複数台あるログサーバのログの中身を確認してDBにデータを挿入するような処理では、前処理としてリモートのログをローカルにコピーする作業が発生するかもしれません。Shellで書くとIPアドレス×ファイルパスの二重for文が必要かもしれませんが、Submarineでは配列で表現できます。しかも1ファイルずつではなく、非同期で複数ファイルを同時にコピーしてくれます


などなど