Node.jsでインフラのテスト、構成管理、オペレーション自動化


とあるプロジェクトで昨年から約1年半、Ansibleでサーバ構築をしていて**書きづらいなー**と思いながらDocker/Kubenetesとか使いたいけど、それに合わせたシステムの改修をが間に合うわけもなく……

という鬱々とした気持ちを抱えておりました。が、不平不満を言うだけでははじまらないと思い立って、自分の好きなように書けるツールを作成してみました

Submarine - https://gitlab.com/mjusui/submarine


私がAnsibleを書きづらいと感じたのは、別にAnsibleが悪いわけではなく、単に**プログラミングパラダイム**の問題です
結論から言うと、タイトルどおり**Node.jsで書きたかったんです**

紹介の前にインフラエンジニア以外の人向けにAnsibleがどういうものなのか、簡単に説明しておきます

# Ansibleとは

サーバ構築、構成管理や自動化のためのツール。特徴としては以下のようなものがあります

* コードは全部`YAML`で表現される
* `オブジェクト指向`や`関数`の概念はなく、条件分岐とループのみで処理が進んでいく(ただしコードをinclude/importできるので多少の再利用性はある)
* 変数は基本すべてグローバルアクセス可能で、再代入も自由(一部スコープを制限する機能はある)
* サーバ構築でよく使う機能(ファイルコピーなど)は`module`という単位でAnsibleやサードパーティが用意してくれたものを使う
* `module`を自前で作成することもできる
* Ansible自体はPythonで開発されており、Pythonの機能や、パッケージを使って`module`も開発されている
* サーバには`ssh`さえできればよく、専用のエージェントをインストールする必要はない

プログラミング経験があまりないインフラエンジニアにも分かりやすいよう、シンプルに設計されているという印象です

# Submarineとは

Node.jsで開発するインフラ管理のFrameworkです。以下のような特徴があります

* Ansibleは構成管理がメインのツールですが、Submairneはサーバのテストもできます
  * テストの結果に応じて、コマンドを実行する/しないが決定されます
* Classと継承を使って、コードの再利用性を高めています
* Node.jsの機能を使えば、変数の再代入を制限したり、スコープを限定することもできます
* サーバの状態を取得する関数、取得した状態をテストする関数、サーバに変更を加える関数を分離することで、安全で読みやすい(条件分岐の少ない)実装ができます
* HTTPサーバを起動して、上記で実装した関数をエンドポイントとして公開する機能があります

今回はSubmarineの基本的な機能をサンプルコードをまじえて、紹介していこうと思います

## サーバの状態を取得する

まずはサーバから情報を取得します

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

const Tutrial = class extends Submarine {
  query(){
    return {
      hostname: 'hostname -s',

      ipv4_addrs: String.raw`
        ip -o -f inet a \
        |awk '{print $4}'
      `
    };
  }
}


const tut=new Tutrial({
  conn: 'bash'
});


tut.current().then((stats)=>{
  console.log(stats);
});
```

Node.jsが読める人は感覚的にわかるかもしれませんが `Submarine` というクラスを継承して `Tutrial` というオリジナルのクラスを定義しています。そして、そのクラスを `new` でインスタンス化して `tut.current()` というところで `Tutrial` クラスに定義した `query` 関数が実行されます

クラスをインスタンス化するときに `{ conn: 'bash' }` という引数を与えることで `Tutrial` クラスに定義した `query` 関数の中のコマンド(`hostname -s` など)が `localhost` の `bash` で実行される、ということを指定しています

クラスをインスタンス化するときに例えば `{ conn: 'ssh', host: <ip address> }` といった具合に指定すれば、指定したIPアドレスにsshして、コマンドが実行されます


## サーバの状態をテストする

今度は取得した情報をテストします

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

const Tutrial = class extends Submarine {
  query(){
    return {
      nonexecutable: String.raw`
        which none \
          2> /dev/null \
          || exit 0
      `,

      executable: String.raw`
        which node \
          2> /dev/null \
          || exit 0
      `,
    };
  }

  test(stats){
    return {
      none_is_not_executable: stats.nonexecutable === '',
      node_is_executable: stats.executable,
    };
  }

}


const tut=new Tutrial({
  conn: 'ssh',
  host: '127.0.0.1'
});


tut.check().then((done)=>{
  console.log(done);
});

```

`query` の部分で `none` というコマンドと `node` というコマンドのパスをwhichコマンドで引くことができるか確認しています。`none` というコマンドは、普通は存在しませんから `test` の中でパスが空 `''` であることを検証しています。一方 `node` というコマンドは、このNode.jsのコードが実行できている以上、どこかに存在しますからwhichコマンドの結果に何らかのパスが含まれていることでしょう。`test` 関数の戻り値に含まれている2つの値(`none_is_not_executable` と `node_is_executable`)は `tut.check()` という部分で評価され論理積(AND)でTrue/Falseが決定されます。結果は `done` 変数に格納されています

## サーバに変更を加える

サーバの状態を取得して、それをテストしました。テストの結果が問題なければ、何もする必要はありませんが、テストで異常が発見された場合は、それを修正しなければなりません。テストがFalseになった場合にだけ実行されるコマンドを以下のように定義します

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

const Tutrial = class extends Submarine {
  query(){
    return {
      file_content: String.raw`
        [ -r /tmp/submarine/hogehoge ] && {
          cat /tmp/submarine/hogehoge
        } || {
          echo 'File not readable' >&2
        }
      `
    };
  }

  test(stats){
    return {
      file_content_is_hogehoge: stats.file_content === 'hogehoge',
    };
  }

  command(props){
    return String.raw`
      mkdir -p /tmp/submarine \
      && echo ${props.msg} > /tmp/submarine/hogehoge
    `;
  }

}


const tut=new Tutrial({
  conn: 'ssh',
  host: '127.0.0.1'
});


tut.correct({
  msg: 'fugafuga'
}).then((done)=>{
  console.log(done);
});
```

`/tmp/submarine/hogehoge` というファイルの内容が `'hogehoge'` であるか確認し、そうでない場合はファイルが作成され、内容は `'hogehoge'` で書き換えられます

`done` にはコマンドが実行された場合は、実行したコマンドのreturn codeやstdoutの情報が格納され、実行されなかった場合は `test` 関数のときの結果が格納されます


### 複数サーバーで実行する

上記の例は1台のサーバに対してコマンドを実行していましたが、複数台のサーバに実行することも可能です

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

const Tutrial = class extends Submarine {
  query(files){
    return {
      availables: String.raw`
        df -P \
        |awk '{print $4}' \
        |grep "^[0-9]*$"
      `
    };

  }

  format(stats){
    return {
      available_max: Array.isArray(stats.availables)
        ? stats.availables.map(
            available => available * 1
          ).sort((a ,b)=>{
            return a < b
              ? -1
              : a == b
                ? 0
                : 1;
          }).reverse()[0]
        : stats.availables * 1
    };
  }

}

const Tutrials = Submarine.hosts(
  host => new Tutrial({
    conn: 'ssh',
    host: host
  }),

  server1,
  server2,
  server3,
  server4,
  server5
);

const tut = new Tutrials();


tut.current().then(
  hosts => hosts.map(
    host => host.available_max
  ).reduce((a, b)=>{
    return a + b;
  }) / 1024 / 1024
).then((available_sum)=>{
  console.log(available_sum);
});
```

`server1` から `server5` がそれぞれ `new Tutrial...` でインスタンス化され `tut.current()` で全台に対して `query` 関数が評価されます。結果はホスト1台のときと同じフォーマットの結果が、配列で返されます(コードの `hosts` に格納されている)

このサンプルコードでは、5台のサーバのディスク空き容量を足し合わせています


## おわりに

他にも、Ansibleの基本的な機能は置き換えられるようか機能が実装されております

各機能のTutrialを鋭意作成中です

https://gitlab.com/mjusui/submarine/tree/master/doc/en/Tutrial