ネットワークAPIを試作してみた with Ruby/Rails/Redis/expect

ネットワーク機器（ルータやスイッチ）の設定作業自動化のために、ネットワークAPIを試作してみました。手持ちのルータ（Cisco 1812J）はNETCONFやREST APIに非対応のため、ルータの設定は伝統的なSSH+expectで設定します。expectを隠蔽するため、ネットワークAPI（REST API、Web）を試作しました。ネットワークAPIは、Ruby/Rails/Redis/expectで実装しています。試作したコードは[GitHub - kooshin/netmgr](https://github.com/kooshin/netmgr)にあります。

<img width="742" alt="スクリーンショット 2016-09-01 7.33.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/12026/dc3bd392-0160-7fbf-7baf-f38ec4a1b3be.png">


# はじめに
ネットワーク設定変更作業プロセス（手順書を作成して、第三者に手順書レビューして頂き、上司の作業承認を得て、はじめて作業実施するプロセス）で消耗してます。同じような簡単な作業はさっさと自動化したいと思い、運用自動化のためにネットワークAPIを試作しました。

ネットワークの自動化の記事は多くあり、参考にさせて頂きました。本記事の内容は下記の2つの組み合わせたような内容です。

* @inoueisseiさんの[NetOps Coding#1（ネットワークAPI作成30分Coding）](http://qiita.com/inoueissei/items/3fd94fec1233d3a31b78) - Netconf/ネットワークAPIで自動化
* @jh1vxwさんの[老害のためのネットワーク自動化入門](http://qiita.com/jh1vxw/items/d2befc5b9181e88c1db6) - Perl/expectで自動化

ネットワークAPIの自由度が高い反面、ネットワーク機器はベンダー依存になり、マルチベンダーに対応するには、最大公約数としてCLI/expectにたどり着くと思います。


## 目的
目的は、日々の運用作業の自動化です。初期構築が完了し、運用フェーズに入った場合、下記のような、似たような作業が多く発生すると思います。

* VPNの設定変更
* インタフェースの設定変更
* 経路の設定変更
* Vlanの設定変更
* 接続先デスクリプションの変更

今回は上記のような、よくある運用作業の自動化を目的としています。そのため、初期構築を含む作業は対象外としています。また、設定する対象も、管理系ネットワークからリモートで設定変更できるルータを対象としています。


## 目的特化型の自動化
作業全ての自動化は非常に難しく、破綻することが目に見えているため、目的を絞って作業を自動化します。よくある作業は自動化の対象とし、自動化のための開発します。年に数回程度の作業は今まで通り手作業でやった方が効率がいいと思います。開発してデバッグする労力と、臨機応変に対応するために人手で手順書作成する労力を天秤にかけて、自動化か手動作業かを選択すると良いと思います。なお、@webdevjpさん曰く、「[手で打ったコンフィグには温かみがある](https://twitter.com/webdevjp/status/747788483758981120)」とのことです。

今回は目的を絞った特化型の運用自動化ネットワークAPIを試作してみました。


## 今回の自動化対象
今回はシスコルータのVRF設定とインタフェース設定を自動化してみました。ユーザごとにVPNが分かれており、VRFとサブインタフェースを大量に設定するような部分を想定して自動化してみました。

下記のコンフィグのVRF名やVLAN IDが微妙に変わるだけの、非常に簡単な例を対象としてみました。試作が目的のため、テストコードやエラー処理は実装しません。

```ios:自動化対象のシスココンフィグ
! VRF定義
vrf definition VRF1
 !
 address-family ipv4
 exit-address-family
!
! サブインタフェース定義
interface FastEthernet1.101
 description VPN1
 encapsulation dot1Q 101
 vrf forwarding VRF1
 ip address 192.168.1.1 255.255.255.0
!
```

ルータのサブインタフェースごとに下記のパラメータを管理します。

ポートのパラメータ

* host - ホストを識別
* vrf - VRF定義名
* port - サブインタフェース名
* vlan - サブインタフェースのVLAN ID
* description - サブインタフェースのデスクリプション
* ipaddr - サブインタフェースのIPアドレス
* netmask - サブインタフェースのサブネットマスク

また、上記とは別にルータごとにログイン情報を管理します。

ホストのパラメータ

* hostname - ホスト名
* connect - 接続用のIPアドレス
* username - ログインユーザ名
* password - ログインパスワード
* enable_password - enable用パスワード


# ネットワークAPI
今回は2つのREST APIを作成します。Ruby on Railsの機能をそのまま使うため、JSONベースのREST APIとなります。

* ホストAPI - ルータのログイン情報を管理
* ポートAPI - ルータのインタフェース情報を管理

```text:ホストAPI
GET    /hosts           -  ホスト一覧
POST   /hosts           -  ホスト登録
GET    /hosts/:host_id  -  ホスト取得
PUT    /hosts/:host_id  -  ホスト更新
DELETE /hosts/:host_id  -  ホスト削除
```

```text:ホストAPIのパラメータ
id:integer              -  ホストの一意識別子（ID）
hostname:string         -  ホスト名
connect:string          -  接続用のIPアドレスまたはDNS名
username:string         -  ログイン用のユーザ名
password:string         -  ログイン用のパスワード
enable_password:string  -  enable用のパスワード
updated_at:date time    -  最終更新日時（読み取りのみ）
```


```text:ポートAPI
GET    /ports                 -  ポート一覧
GET    /hosts/:host_id/ports  -  特定ホストのポート一覧
POST   /hosts/:host_id/ports  -  ポート登録
PUT    /ports/:port_id        -  ポート更新
DELETE /ports/:port_id        -  ポート削除
```

```text:ポートAPIのパラメータ
id:integer             -  ポートの一意識別子（ID）
host_id:integer        -  ホストの一意識別子（ID）
name:string            -  インタフェース名
shutdown:boolean       -  インタフェースが閉塞の場合true
vrf:string             -  VRF名
vlan:string            -  VLAN ID
ipaddr:string          -  IPアドレス
netmask:string         -  サブネットマスク
description:string     -  デスクリプション
configured:boolean     -  設定済みかどうか（読み取りのみ）
updated_at:datetime    -  最終更新日時（読み取りのみ）

```

## 仕組み・構成
ネットワークAPIは、3つのコンポーネントで分かれています。SDNっぽい感じに、ノースバウンド（Northbound）とサウスバウンド（Southbound）に分けています。

* ノースバウンド：ホストAPIとポートAPIで、リクエスト受付し、データベースに反映
* ジョブキュー：受け付けたリクエストをジョブとしてキューに登録
* サウスバウンド：逐次ジョブを実行し、expectでルータの設定変更

<img width="733" alt="スクリーンショット 2016-09-01 7.32.58.png" src="https://qiita-image-store.s3.amazonaws.com/0/12026/fb178e44-0dcb-c94b-e5e3-b4c33b82d11b.png">


ネットワーク設定の根幹はルータのCLIベースでコンフィグを変更します。サウスバウンドはexpectベースで設定を投入し、ノースバウンドはREST APIとWeb画面を用意しています。


## ノースバウンド - Ruby/Rails
WebフレームワークのRuby on Railsで作成します。Railsで作成することで、一度に2つの機能を作成することが可能です。

* REST API - JSONベースのAPI
* WEB UI - Webから設定変更

ホストAPIでは、ルータのログイン情報の登録・更新・削除が可能です。ポートAPIでは、インタフェースの定義の登録・更新・削除が可能です。ルータの設定変更をジョブとして、ジョブキューに登録します。


## ジョブキュー - Redis
ルータの設定変更は時間がかかるため、ジョブキューを利用し、リクエストを受け付けて、非同期で設定変更します。ジョブキューは同時実行を1としています。同時に複数のルータの設定変更が発生しないように抑制することを目的としています。ジョブキューのバックエンドにはキーバリューストアのRedis/Sidekiqを利用しています。


## サウスバウンド - expect
シスコルータがNetconfやREST APIに対応していないため、expectを利用し、SSHした上で、コンフィグを流し込みます。


## 環境
Linux上でRuby on Railsで試作しました。ジョブキューとしてRedis/Sidekiqを採用しています。expectは、Rubyライブラリのexpect4rを利用しています。

* Ubuntu Linux 16.04 LTS
* ミドルウェア
 * redis 3.0.6
 * sqlite 3.11
* 開発言語・主要ライブラリ
 * Ruby 2.3.1
 * Rails 5.0.0.1
 * Sidekiq 4.1.2
 * expect4r 0.0.11
* ルータ
 * CISCO1812J 2台


# 実装
プログラム一式はGitHubにあります。

* [GitHub - kooshin/netmgr](https://github.com/kooshin/netmgr)

## ノースバウンド - REST API部/Web画面部
app/modelsディレクトリ内は、モデル（データベース）に関連したプログラムです。データの妥当性の検査などをします。
app/controllersディレクトリ内は、コントローラ（制御）に関連したプログラムです。API/Webで共通で、一覧表示・登録・更新・削除の制御します。
app/viewsディレクトリ内は、ビュー（見た目）に関連したプログラムです。APIの場合はJSON、Webの場合はWebフォームを表示します。


ホストAPI/Web

* app/models/host.rb - データベース定義・バリデーション（API/Web共通）
* app/controllers/hosts_controller.rb - 制御部（API/Web共通）
* app/views/hosts/_host.json.jbuilder - API用JSON生成（共通）
* app/views/hosts/index.json.jbuilder - API用JSON生成（一覧）
* app/views/ports/show.json.jbuilder - API用JSON生成（詳細）
* app/views/hosts/_form.html.slim - Webフォーム部品
* app/views/hosts/index.html.slim - 一覧表示Web画面
* app/views/hosts/new.html.slim - 新規登録Web画面
* app/views/hosts/edit.html.slim - 編集Web画面

ポートAPI/Web

* app/models/port.rb - データベース定義・バリデーション（API/Web共通）
* app/controllers/ports_controller.rb - 制御部（API/Web共通）
* app/views/ports/_port.json.jbuilder - API用JSON生成（共通）
* app/views/ports/index.json.jbuilder - API用JSON生成（一覧）
* app/views/hosts/show.json.jbuilder - API用JSON生成（詳細）
* app/views/ports/_form.html.slim - Webフォーム部品
* app/views/ports/index.html.slim - 一覧表示Web画面
* app/views/ports/new.html.slim - 新規登録Web画面
* app/views/ports/edit.html.slim - 編集Web画面


## ジョブキュー部
ジョブキューに登録する部分は下記の通りです。ジョブキューに登録することで、ルータの設定を待たずに、非同期に実行することができます。また、同時に同じルータの設定をしてしまうことを防ぐことができます。
ジョブキューに登録した後は、ジョブキューのRedisにジョブが登録され、Sidekiqにより逐次ルータの設定変更します。

```rb:app/controllers/ports_controller.rb
  # 新規登録
  def create
〜略〜
    respond_to do |format|
      if @port.save
        PortConfigWorker.perform_async(@port.id) # ジョブキューに登録、新規登
〜略〜
      end
    end
  end

  # 更新
  def update
〜略〜
    respond_to do |format|
      if @port.update(port_params)
        PortConfigWorker.perform_async(@port.id) # ジョブキューに登録、更新
〜略〜
      end
    end
  end

  # 削除
  def destroy
〜略〜
    respond_to do |format|
      PortUnconfigWorker.perform_async(@port.id) # ジョブキューに登録、削除
〜略〜
    end
  end
```


## サウスバウンド - expect部
実際のルータの設定変更はSidekiqのワーカーを利用しています。下記の2つがポート設定と設定削除を担います。中身はexpect4rを利用し、ルータにSSHして、CLI経由で設定を実施します。expect4rについては、[Rubyのexpect4rでCiscoルータにTelnet/SSHしてコマンド実行する](http://qiita.com/kooshin/items/27f2b9ed5c8896279bcf)を参照してください。

* app/workers/port_config_worker.rb   - ポート設定追加処理
* app/workers/port_unconfig_worker.rb - ポート削除処理

例として、ポート設定処理を抜粋します。CLIから設定するようにルータにログインした後、configureモードに遷移し、必要なコンフィグを投入し、最後にルータのコンフィグを保存します。

```rb:app/workers/port_config_worker.rb
class PortConfigWorker
  include Sidekiq::Worker

  # ジョブキューから渡されるパラメータはポートIDのみ
  def perform(port_id)
    @port = Port.find(port_id)
    @host = @port.host

    # ログイン処理
    @ios = Expect4r::Ios.new_ssh(
      host: @host.connect,
      user: @host.username,
      pwd: @host.password,
      enable_secret: @host.enable_password
    )
    @ios.login

    # ここからルータの設定を投入
    @ios.config
    @ios.exp_send %{
vrf definition #{@port.vrf}
 address-family ipv4
    }
〜略〜
    @ios.exp_send 'end'

    # ルータのコンフィグを保存
    @ios.putline "copy running-config startup-config\r", no_trim: true

    # 設定済みにポート情報を更新する
    @port.configured = true
    @port.save!
  end

end
```


# 実行結果
下記の前提環境で実行します。

* Ruby 2.3.1がインストール済み
* Redisがインストール済み＆ローカルマシン上で実行中

```bash:実行結果
# GitHubからダウンロードし、ライブラリをインストール
$ git clone https://github.com/kooshin/netmgr.git
$ cd netmgr
$ bundle install --path vendor/bundler

# Railsを実行
$ bundle exec rails db:migrate
$ bundle exec rails server

# 別のウィンドウで、Sidekiqを実行
$ bundle exec sidekiq
```

## APIの実行結果
cURLとjqを使ってネットワークAPIを試してみます。今回は、rt2-osakaに新しくサブインタフェースFastEthernet1.103を作成します。

ホストAPIで、ホスト一覧を取得します。2つのホストが登録されており、rt2-osakaはhost_id:2であることがわかります。

```bash:ホストAPIで、ホスト一覧取得
$ curl -X GET http://localhost:3000/hosts.json | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   493    0   493    0     0  43333      0 --:--:-- --:--:-- --:--:-- 44818
[
  {
    "id": 1,
    "hostname": "rt1-tokyo",
    "connect": "192.168.88.101",
    "username": "cisco",
    "password": "cisco",
    "enable_password": "cisco",
    "created_at": "2016-08-31T06:29:01.609Z",
    "updated_at": "2016-08-31T12:06:43.359Z",
    "url": "http://localhost:3000/hosts/1.json"
  },
  {
    "id": 2,
    "hostname": "rt2-osaka",
    "connect": "192.168.88.102",
    "username": "cisco",
    "password": "cisco",
    "enable_password": "cisco",
    "created_at": "2016-08-31T06:29:45.922Z",
    "updated_at": "2016-08-31T12:06:49.335Z",
    "url": "http://localhost:3000/hosts/2.json"
  }
]
```

ポートAPIで、host_id:2を指定して、ポート一覧を取得します。2つのポートが登録されていることがわかります。

```bash:ポートAPIで、host_id:2のポート一覧取得
$ curl -X GET http://localhost:3000/hosts/2/ports.json | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   603    0   603    0     0  34952      0 --:--:-- --:--:-- --:--:-- 35470
[
  {
    "id": 5,
    "host_id": 2,
    "name": "FastEthernet1.101",
    "shutdown": false,
    "vrf": "VRF1",
    "vlan": "101",
    "ipaddr": "192.168.1.2",
    "netmask": "255.255.255.0",
    "description": "VPN1",
    "configured": true,
    "created_at": "2016-08-31T07:06:48.718Z",
    "updated_at": "2016-08-31T12:12:18.114Z",
    "url": "http://localhost:3000/ports/5.json"
  },
  {
    "id": 11,
    "host_id": 2,
    "name": "FastEthernet1.102",
    "shutdown": false,
    "vrf": "VRF2",
    "vlan": "102",
    "ipaddr": "192.168.1.2",
    "netmask": "255.255.255.0",
    "description": "",
    "configured": true,
    "created_at": "2016-08-31T12:12:59.415Z",
    "updated_at": "2016-08-31T12:13:07.271Z",
    "url": "http://localhost:3000/ports/11.json"
  }
]
```

ポートAPIで、host_id:2に新規のサブインタフェースFastEthernet1.103を登録します。登録パラメータはJSON形式で渡します。リクエスト内容に問題がなければ、登録結果がレスポンスとしてもらえます。port_id:12で登録されたことがわかります。また、configured:falseとなっており、まだルータには反映されていないことがかります。

```bash:ポートAPIで、host_id:2の新規ポート登録
$ curl -X POST -H "Content-Type: application/json" -d '{"port": {"name": "FastEthernet1.103", "vlan": "103", "vrf": "VRF1", "ipaddr": "192.168.2.2", "netmask": "255.255.255.0"}}' \
http://localhost:3000/hosts/2/ports.json | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   423    0   301  100   122   8176   3314 --:--:-- --:--:-- --:--:--  8361
{
  "id": 12,
  "host_id": 2,
  "name": "FastEthernet1.103",
  "shutdown": null,
  "vrf": "VRF1",
  "vlan": "103",
  "ipaddr": "192.168.2.2",
  "netmask": "255.255.255.0",
  "description": null,
  "configured": false,
  "created_at": "2016-08-31T15:11:45.678Z",
  "updated_at": "2016-08-31T15:11:45.678Z",
  "url": "http://localhost:3000/ports/12.json"
}
```

改めてhost_id:2のポート一覧を取得します。先ほど登録したport_id:12について、configured:trueとなっており、設定が反映されていることが確認できます。

```bash:ポートAPIで、host_id:2のポート一覧取得
$ curl -X GET http://localhost:3000/hosts/2/ports.json | jq .
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   904    0   904    0     0  32298      0 --:--:-- --:--:-- --:--:-- 33481
[
  {
    "id": 5,
    "host_id": 2,
    "name": "FastEthernet1.101",
    "shutdown": false,
    "vrf": "VRF1",
    "vlan": "101",
    "ipaddr": "192.168.1.2",
    "netmask": "255.255.255.0",
    "description": "VPN1",
    "configured": true,
    "created_at": "2016-08-31T07:06:48.718Z",
    "updated_at": "2016-08-31T12:12:18.114Z",
    "url": "http://localhost:3000/ports/5.json"
  },
  {
    "id": 11,
    "host_id": 2,
    "name": "FastEthernet1.102",
    "shutdown": false,
    "vrf": "VRF2",
    "vlan": "102",
    "ipaddr": "192.168.1.2",
    "netmask": "255.255.255.0",
    "description": "",
    "configured": true,
    "created_at": "2016-08-31T12:12:59.415Z",
    "updated_at": "2016-08-31T12:13:07.271Z",
    "url": "http://localhost:3000/ports/11.json"
  },
  {
    "id": 12,
    "host_id": 2,
    "name": "FastEthernet1.103",
    "shutdown": null,
    "vrf": "VRF1",
    "vlan": "103",
    "ipaddr": "192.168.2.2",
    "netmask": "255.255.255.0",
    "description": null,
    "configured": true,
    "created_at": "2016-08-31T15:11:45.678Z",
    "updated_at": "2016-08-31T15:11:53.524Z",
    "url": "http://localhost:3000/ports/12.json"
  }
]
```

## Webの実行結果
上記のAPIと同じことを今度はWeb UIから実施します。rt2-osakaにサブインタフェースFastEthernet1.105を定義します。

* Web UI - [http://localhost:3000/](http://localhost:3000/)

ホストの一覧画面（[http://localhost:3000/hosts](http://localhost:3000/hosts)）では、ログイン情報を確認できます。この画面から、ホストにポートを追加できます。
![Screenshot from 2016-09-01 00-21-58.png](https://qiita-image-store.s3.amazonaws.com/0/12026/540b7486-a629-42e5-9adf-e2363f33e04c.png)

ポートの一覧画面（[http://localhost:3000/ports](http://localhost:3000/ports)）では、各ホストに紐づくポートの情報を確認できます。
![Screenshot from 2016-09-01 00-22-12.png](https://qiita-image-store.s3.amazonaws.com/0/12026/d3f5e757-fbcf-82bc-60ce-ee3a0f2e75f5.png)

今回は、ホストの一覧画面から、rt2-osakaの「Add Port」からポートを追加します。今回はFastEthernet1.105を追加します。正しい情報以外を入力した場合は、妥当性チェックでエラーとなります。
![Screenshot from 2016-09-01 00-23-32.png](https://qiita-image-store.s3.amazonaws.com/0/12026/a4cb4bd9-d36a-b49c-2010-5c72b9e28811.png)

必須項目が抜けている場合や、IPアドレスが正しくない場合は、エラーとなり、再度入力を促されます。
![Screenshot from 2016-09-01 00-31-48.png](https://qiita-image-store.s3.amazonaws.com/0/12026/ca983bd0-3620-7779-39ff-cf3025c782f9.png)


入力した値が問題なければ、Port configuration was successfuly scheduledとなります。このタイミングでは、configuredがfalseであることが確認できます。
![Screenshot from 2016-09-01 00-29-18.png](https://qiita-image-store.s3.amazonaws.com/0/12026/2048a77e-af4f-f284-656b-12bec4801e48.png)

しばらくしてリロードすると、configuredがtrueに変化していることが確認できます。これでルータに設定反映が完了しました。
![Screenshot from 2016-09-01 00-23-49.png](https://qiita-image-store.s3.amazonaws.com/0/12026/bf41e7c5-604d-8691-f6d7-924e371c7bdf.png)


## ジョブキューの状態確認
[http://localhost:3000/sidekiq](http://localhost:3000/sidekiq)にアクセスすることで、Sidekiqのダッシュボードを表示して、ジョブキューの状態確認ができます。ジョブがエラーで実行できなかった場合は、自動的にリトライされます。エラーとなったジョブを削除することもできます。

![Screenshot from 2016-09-01 07-29-42.png](https://qiita-image-store.s3.amazonaws.com/0/12026/6a8f179c-e49b-79cd-ecfe-7c968e29f6f9.png)


# さいごに
実運用に耐えるためには、予測不可能なパラメータを入力された場合などの、エラー処理などを作成していく必要があります。そうすると、だんだん、開発するモチベーションが下がり、人手でやった方がいいじゃんんという結論にたどり着いてしまいそうです。そうなる前に、目的を絞って、簡単な自動化から初めて行きたいと思います。

パラメータでサブインタフェース名を変更する場合は、一度、サブインタフェースを消したうえで、新しくサブインタフェースを作成するなど、複雑な条件で実装をしていく必要が出てきます。そのため、実際の現場では、一部実装は諦めて、運用でカバーすることになりそうな気がします。。。