Ansibleのつらみ - 俺ならこう実装するのに!



昨年からだいぶサボっていましたが、個人的にAnsibleに足りないと感じている機能を実装した、オレオレ構成管理ツールであるSubmarine.jsというツールを開発しているので、Ansibleとの比較で紹介していこうと思います

リポジトリ: https://gitlab.com/mjusui/submarine2

Ansibleにこんな機能あったらいいのにという願望ととらえて読んでいただければと思います


## Python環境構築のハマりどころ

Ansibleをインストールするのに、まず大事なのはPython環境を整えることです。その際の注意点として以下のようなものがあります

- Python, pip, Ansibleそれぞれのバージョン管理が必要
  - Python2系と3系で互換性がない
  - `pip install` するときはバージョンを指定しよう
  - 同じAnsible2.x系でも、後方互換性がない機能がある
  - pyenv/virtualenvがおすすめ

一方、Submarine.jsでは、Node.jsのバージョン以外には極力依存しないように実装されています

- Node.jsのバージョンのみに依存
  - Node.jsは、ブラウザで動作するJavaScriptの派生なだけあって比較的、後方互換性がある
  - パッケージはいっさい使わないので、依存関係の解消は不要
  - 動作が安定してきたら、Submarine.js自体の後方互換性も長めに維持していく予定(願望)
  - nvm環境(Pythonで言うところのpyenvに相当)がおすすめ


## Dynamic Inventoryは、もっと簡単にできる

Dynamic InventoryはPlaybookを実行する対象のインベントリを、動的に生成する機能ですが、以下の点でとっつきにくさがあります

- Dynamic Inventoryの特徴
  - スクリプトで複雑なJSONを生成する必要がある
  - 生成されたインベントリの一覧を、手軽に確認できない

これに対してSubmarine.jsでは、generatorとfilterという2つの概念を組み合わせることで、簡単に実現できるようになっています

```collect.js
module.exports={
  collect: [{
    type: 'gen/bash',
    cmd: 'echo 172.17.0.{1..254}',
  }, {
    type: 'fil/ping',
  }],
};
```

このコードは172.17.0.1から254のIPアドレスに対してpingを打って、応答があったホストだけ抽出するSubmarine.jsのコードです

Node.jsのコードを書く必要があるのですが、構成管理に関わる部分は、ほとんどJSONが書ければ問題ありません

`type: 'gen/bash'` というのは、Bashでコマンド実行した結果をもとにターゲットホストのリストを生成するgeneratorです
そして `type: 'fil/ping'` というのはgeneratorで生成したリストにpingを打って、応答結果に応じてリストを絞り込むfilterです

このようにSubmarine.jsでは、シンプルなJSONで、AnsibleのDynamic Inventoryに相当する機能が実現できます



## 冪等性を担保するのは、ユーザの仕事

Ansibleの重要な概念の一つとして、冪等性というものがあります

Ansibleでは、これを担保するために膨大な数のモジュール群が提供されていますが、それらがバージョンごとに微妙に挙動が変わったりして、メンテナンスが大変だったりします

また、複雑なことをしたい場合は、結局自分で冪等なスクリプトを書く必要があり、それもなかなか骨の折れる作業です


Submarine.jsでは、冪等なモジュールを提供するのではなく、Dockerfileに学び、イミュータブルなShellScriptを書くというアプローチを取っています

1度ShellScriptを実行すると、サーバ側にロックファイルを生成し、2回目以降はロックファイルがある場合は、実行をスキップするように実装されています

コードはこんな感じ

```provisoin.js
module.exports={
  provision: {
    gen: 'mysql-server-1',
    opts: ['-l submarine'],
    cmds: [{
      name: 'install-mysql',
      cmd: String.raw`
        cd /usr/local/src/mysql \
        && ./configure \
        && make install
      `,
    }, {
      name: 'install-curl',
      cmd: 'apt install -y curl',
    }]
  },
};
```

`cmds` という配列の中に、実行するコマンド(`cmd`)と、ロックファイルの名前として `name` を指定します

ちなみに1度実行したスクリプトを修正したい場合は、既存の `cmd` を変更するのではなく、`cmds` の末尾に、新しく処理を追加するようにします
パッチを当てるような感覚ですね


こうすれば冪等性に配慮してツール固有のモジュールの使い方を学んだり、if文を書いたりする必要はありません


## インフラの構成管理は、プログラミングよりDatabase設計に近い

インフラの構成をコードとして管理することをIaC(Infrastructure as Code)と言いますが、実際インフラの構成管理とプログラミングは一緒くたに扱うことができない異なる特徴があります

まずインフラの世界では、ミドルウェアごとにインストールのしかたや、設定ファイルの書き方が異なり、プログラミングのように十分に整備されたSyntaxというものがありません

またプログラミングの主たる目的は、メモリやDatabase上のデータを参照/変更することです。一方インフラの場合、例えばミドルウェアのインストールなどは、それよりも処理コストがかかる上、一度インストールしたソフトウエアのバージョンは、簡単には変更できません(この点コンテナ技術は優れていると言えるでしょう)

インフラの世界では、プログラミングよりもコストがかかる、可逆性の低い状態遷移があるのです
そのためバグが発見されたら、古いタグでリリースし直せば解決、とはいかないことも多いです



Ansibleでは、サーバAとBで同じ構築手順を共有していた場合、roleという単位でひとまとめにすることはよくありますが、これをすると、例えばサーバAの構築手順を変更する場合、この共有しているコードに手を加えるとサーバBにも影響が出てしまうので、メンテナンスしづらいということはよくあります

プログラミングの世界ではDRYということが、しばしば美徳とされますが、IaCの世界では必ずしもそうではないのです

この点Submarine.jsでは、構築手順を共有しているサーバが複数あったとしても、構成ファイルは別々に分けることを推奨することにしています
実際、構成情報は1つのファイルにまとめて書くようになっているので、コードの共有がしづらいようになっています



## インフラの世代管理に弱いAnsible

インフラはプログラミングとは違い、構成変更の敷居が高いことが分かりました。そのこともあり、インフラの世界では、本番環境に複数世代のインフラが並行稼働するようなこともあります

そしてAnsibleは、この複数世代の構成管理が、あまり得意で無いように感じます。roleを使ってDRYを実現することで、特定のサーバだけ構成変更するようなメンテナンスがしづらくなることは、さきにも述べました

このあたりのことは[昨年の私の記事](https://qiita.com/mjusui/items/629fa699f489d95b3778)にも、少し書きました


Submarine.jsでは、構成情報に「世代」の概念を導入しています

```provision.js
module.exports={
  provision: {
    gen: 'mysql-server-1',
    opts: ['-l submarine'],
    cmds: [{
      name: 'install-mysql',
      cmd: String.raw`
        cd /usr/local/src/mysql \
        && ./configure \
        && make install
      `,
    }, {
      name: 'install-curl',
      cmd: 'apt install -y curl',
    }]
  },
};
```

これは先ほどの、イミュータブルなShellScriptの説明でも出てきたコードです
ここで `gen: 'submarien-test-2'` と書かれた部分が世代にあたります

Submarine.jsは、はじめにスクリプトを実行するときに、サーバ側にファイルを生成し、この世代の情報を保持しておいて、2回目以降は、この世代情報が一致しないサーバには、スクリプトを実行しないような挙動をします

つまり、ある世代のサーバに、別の世代のコードが実行されないように保護してくれるのです

これは例えば、あるサーバのグループのうち、試しに一部のサーバだけをリプレース(ローリングアップデート)したいときなどに役に立ちます。OSやミドルウェアアップデートをするときには当然、インストール手順も変わるので、provisionの定義も変更する必要があります。そこで変更した定義を構築済みの旧世代のサーバには実行せず、新しくOSをインストールした新世代のサーバにのみ実行することができるのです


## サーバを構築したら、正しくできたか確認しよう

構成管理・自動化ツールであるAnsibleとは、少し離れますがSubmarine.jsには、構築したサーバをテストする機能もあります

サーバのテストというと、一般的にはAnsibleエコシステムの1つであるTestInfraや、Ruby系のツールであるServerspecが上げられますが、これらのツールでは、サーバから値を取得するためにモジュールが提供されています

一方Submarine.jsでは、値の収集もShellScriptで行います

```query-and-test.js
module.exports={
  query: {
    opts: ['-l submarine'],
    query: {
      hostname: 'hostname -s',
      submarine_user: 'cat /etc/passwd|grep submarine|wc -l'
    }
  },

  test: {
    func: (host, all)=>{
      return {
        host_count: Object.keys(all).length === 10,
        hostname: host.hostname === 'ubu2004-submarine-target',
        submarine_user: host.submarine_user === '1',
      };
  },
};
```

上記の `query` という部分で、テストしたい値を収集するShellScriptを書きます
そして `test` という部分で、取得した値を評価する関数を定義しています

構成情報とテストを同じファイルに記述できるため、コードから、よりサーバ要件を理解しやすくなります



## Ansibleの利点

ここまでで、個人的にAnsibleの至らないと感じている部分を挙げてきましたが、Ansibleでないとできないことも沢山あるので、思いつく限り記載しておきます

- Network機器のmodule群が充実している
- Windows系のmodule群が充実している
- Jinja2のTemplateが使える