nginx〜go〜mongoDBを使ったREST-WebAPIサーバのパフォーマンスチューニング 第4回 DB接続もコネクションプーリングで

この記事ではAWS ec2上のCentOS7にインストールしたnginx 1.10.2とgo 1.7を使っています。mongoDB 3.4を追加しました。

連載一覧
[第1回 REST-WebAPIの特徴に合わせてチューニングしよう] (http://qiita.com/toast-uz/items/828bbb48d58eb65f1f9f)
[第2回 nginx基本チューニング(有効なのはtcp_nopushだけ）] (http://qiita.com/toast-uz/items/48754a95aa0c60db67f5)
[第3回 バックエンドはコネクションプーリングで] (http://qiita.com/toast-uz/items/b459100189efb995b9c5)
[第4回 DB接続もコネクションプーリングで] (http://qiita.com/toast-uz/items/9c1b97bee8f4a7c13bfc)

今回は、testサーバ〜Webサーバ〜apサーバ〜dbサーバの構成にして、dbサーバにmongoDB 3.4をインストールしました。全てt2.microを利用しています。ついに、REST-WebAPIサーバという連載表題にふさわしい構成になりました。（感無量）

mongoDB 3.4のインストールについては、[MongoDB 3系を CentOS7 にインストール](http://qiita.com/SOJO/items/dc5bf9b4375eab14991b)を参考にしました。そのままtestデータベースのdbAdminおよびreadWrite権限を持つ、ユーザ、パスワードを設定しておきます。

# 1. 準備

前回の環境をベースに、新たにmongoDBと接続するアプリをapサーバに作成します。

# 1.1. webサーバ

nginxのdefault.confを以下のように書き換えます。前回までの静的コンテンツを返すアプリにコネクションプーリングでアクセスするapi2kを比較対象用に残したまま、新たにapi3kを作ります。

```nginx:default.conf
upstream api2k {
     server 172.31.24.119:8080;
     keepalive 100;
}
upstream api3k {
     server 172.31.24.119:8081;
     keepalive 100;
}

server {

# 中略（元々記載されている内容 ※index.htmlを含む）

    location /api2k/ {
        proxy_pass http://api2k/;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
    location /api3k/ {
        proxy_pass http://api3k/;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}
```

細かいですが、api2kについても、locationのURLの記述を前回と少し変えています。この方がアプリにうまくURLが渡せるようです。

なお、このまま動かすと、前回と微妙に異なる、SELinuxに起因するセキュリティエラーが発生します。以下の設定により、動作できるようになります。

```:webサーバ
# setsebool httpd_can_network_connect on -P
```

# 1.2. apサーバ

api2については前回と同様です。今回のキモである、api3を新規作成します。

api3は以下のような動作を行います。

+ POSTリクエストには、リクエストBodyをJSONとして、リクエストURLとセットでmongoDBに書き込み、200 OKを返信する。
    + 既に記録済URLが存在すれば、JSON-Bodyを既存のものに上書きする。
    + リクエストBodyがJSONでなければ、400 Bad Requestを返信する。
+ GETリクエストには、リクエストURLに対応するJSONをmongoDBから読み出し、Bodyとして200 OKとともに返信する。
    + リクエストURLに対応する記録がmongoDBに存在しない場合、404 Not Foundを返信する。
+ mongoDBとの間に100コネクションをプーリングする。
    + リクエストに対して、利用するコネクションを乱択する。

goによるプログラムソースは以下になります。ソースのコメントも参考にしてください。  

```go:api3.go
package main

import (
  "fmt"
  "net/http"
  "time"
  "math/rand"
  "io/ioutil"
  "encoding/json"
  mgo "gopkg.in/mgo.v2"
  "gopkg.in/mgo.v2/bson"
)

// mongoDBとの間のコネクションプーリング数
const maxSessions = 100
// プーリングするコネクションを保持する配列
var session []*mgo.Session

// mongoDBに保存するデータ形式
// POST/GETするJSONの中身は_dataの中にネストして記述
// （構造が不定なので interface{}を使う）
// リクエストURLは_pathに記述
type mongoDoc struct {
  Data interface{} `json:"_data" bson:"_data"`
  Path string `json:"_path" bson:"_path"`
}

// リクエスト処理ハンドラ
func handler(w http.ResponseWriter, r *http.Request) {
  // プリーリングされたどのコネクションを使うかを乱択する
  // なお、乱数はグローバルなものは使わずにローカル生成していることに注意
  COL := session[rand.New(rand.NewSource(time.Now().UnixNano())).Intn(maxSessions)].DB("test").C("COL")
  // GETとPOSTの処理分岐
  var doc mongoDoc
  switch r.Method {
  case "GET": // GET時の処理
    // _pathに指定URLで一致するものがあるか検索
    iter := COL.Find(bson.M{"_path": r.RequestURI}).Iter()
    if iter.Next(&doc) {
      // 一致の場合 _dataの中身のJSONを文字列に変換
      jsonstring, err2 := json.Marshal(doc.Data)
      if err2 != nil {
        panic(err2)
      }
      // JSON文字列をbodyにして返す
      fmt.Fprintf(w, string(jsonstring))
    } else {
      // 不一致の場合は、404 Not Foundを返す
      http.NotFound(w, r)
    }
  case "POST": // POST時の処理
    doc.Path = r.RequestURI
    // リクエストBodyをJSONに変換して_dataに入れる
    jsonstring, _ := ioutil.ReadAll(r.Body)
    json.Unmarshal(jsonstring, &doc.Data)
    if doc.Data != nil {
      // 既に登録済のURLであれば上書き、未登録であれば新規登録する
      _, err := COL.Upsert(bson.M{"_path": doc.Path}, &doc)
      if err != nil {
        panic(err)
      }
    } else {
　　　// リクエストBodyのJSON化に失敗したら、400 Bad Requestを返す
      w.WriteHeader(http.StatusBadRequest)
    }
  }
}

// ここがメインルーチン
func main() {
  // mongoDBのコネクションを作っておく
  mongoInfo := &mgo.DialInfo{
    Addrs:    []string{"172.31.22.189:27017"},
    Timeout:  20 * time.Second,
    Database: "test",
    Username: "test",
    Password: "test",
    Source: "test",
  }
  session = make([]*mgo.Session, maxSessions)
  for i := 0; i < maxSessions; i++ {
    var err error
    session[i], err = mgo.DialWithInfo(mongoInfo)
    if err != nil {
      panic(err)
    }
  }

  // ポート8081でhttpを待ち受けし、リクエストが来たらハンドラを呼ぶ
  http.HandleFunc("/", handler)
  err := http.ListenAndServe(":8081", nil);
  if err != nil {
      return
  }
}
```

mongDBとの間のコネクションをプーリングするのは、「[MongoDBベンチマーク 1インスタンスで8万insert/秒達成] (http://qiita.com/toast-uz/items/0aad7cb5281b28fd2947)」において、CPUスケールアップの可能性まで考慮して最適であると結論づけているためです。

コネクションの選択は、通常であれば順番にサイクリックに選択していくラウンドロビン方式とするところを、あえて、馴染みがないかもしれませんが、乱数により選択する「乱択方式」としました。これは、ラウンドロビンに必要となるグローバル変数の書き込みロックがもたらす待ちを嫌ったためです。ここについては、今後の連載で、定量的な性能比較をしてみると面白いかもしれません。

ベンチマークのためにデータを１つPOSTしておきます。

```:testサーバ
$ curl http://172.31.20.18/api3k/temp/ -X POST -d '{"temp":25}'
```

試しにGETすると登録したデータが取得できます。

```:testサーバ
$ curl http://172.31.20.18/api3k/temp/
{"temp":25}
```

# 2. 測定結果

遅延100msを引加したtestサーバでabを実行させます。api3kについては、GETとPOSTの双方を測定します。

```:testサーバ
$ ab -c 10000 -n 1000000 -r http://172.31.20.18/api2k/
$ ab -c 10000 -n 1000000 -r http://172.31.20.18/api3k/temp/
$ echo -n "{\"temp\":21}" > postfile
$ ab -c 10000 -n 1000000 -p postfile -r http://172.31.20.18/api3k/temp/
```

測定結果を以下に示します。

| アクセス先 | 動作 | Requests/sec | エラー数 |
|:---------|:---------|------:|-----:|
| /api2k/ |静的コンテンツ|1,851|1,121|
| /api3k/temp/ |mongoDB-GET|1,879|1,313|
| /api3k/temp/ |mongoDB-POST|1,867|781|

api2kについては、前回と同一条件のはずなのですが、なぜか大きく性能が減っています。

さらに、結果を分かりやすくするために、testサーバ〜webサーバのボトルネックの影響を外して、webサーバローカルでabをkeepaliveありでも測定してみます。

```:webサーバ
$ ab -c 100 -n 1000000 -k http://172.31.20.18/api2k/
$ ab -c 100 -n 1000000 -k http://172.31.20.18/api3k/temp/
$ echo -n "{\"temp\":21}" > postfile
$ ab -c 100 -n 1000000 -p postfile -k http://172.31.20.18/api3k/temp/
```

測定結果を以下に示します。

| アクセス先 | 動作 | Requests/sec | エラー数 |
|:---------|:---------|------:|-----:|
| /api2k/ |静的コンテンツ|9,318|0|
| /api3k/temp/ |mongoDB-GET|4,496|0|
| /api3k/temp/ |mongoDB-POST|4,554|0|

こちらのapi2kの値は、前回よりやや落ちますが、大きな変化はありません。

さすがに静的コンテンツよりも、mongoDBにアクセスした方がやや性能が落ちますが、それでも、全体のボトルネックは、testサーバ〜webサーバ間にあることがわかります。

また、GET/POSTでは大きな差はありませんでした。「[MongoDBベンチマーク 1インスタンスで8万insert/秒達成] (http://qiita.com/toast-uz/items/0aad7cb5281b28fd2947)」において、mongoDBに対して100コネクションプーリングして1CPUで1万アクセス/秒近い性能が出ていましたので、5千アクセス/秒程度では、読み出し/書き込みの差は出てこないものと思われます。

# 補足. POSTなのかPUTなのか

今回は書き込みのメソッドでPOSTを選択しました。しかし、厳密に言えば動作にはベキ等性（すでに存在していれば上書きする仕様であるため、同じ内容で何回POSTしても、１回のPOSTと結果は変わらない）があり、PUTを使うのが適切かもしれません。