Node.js Express4で Cloudant セッション・ストアの水平分散クラスタを構成する


Bluemixのランタイムは、コンソール画面からクリックするだけで、インスタンスの数を増やし、能力を増強する事ができます。しかし、この機能を利用するには、アプリケーションが稼働する各インスタンス間で、セッションの情報が共有される必要があります。 Cloudant をセッション・ストアとして利用して、水平分散クラスタを実装するメモです。

Bluemix ランタイムのスケーラビリティ

Bluemix のコンソール画面で、次のスクリーンコピーの赤矢印部分をクリックすることで、アプリケーションが動作するサーバー部分(ランタイム/コンテナをインスタンスと呼んでいる)を増設して、処理能力を向上させる事ができます。

水平分散クラスタ構成

この時、Bluemix / CloudFoundry のロードバランサー(gorouter)が増設したインスタンスに対して要求を分配します。 この分配のアルゴリズムは、ラウンド・ロビンで均等にリクエストを分配します。(1),(2) しかし、これはウェブ・アプリケーションが対応していないと、問題を起こすケースがあり、詳しくみていきます。

セッション・ストアが無い場合の不具合

リクエストをラウンド・ロビンで均等に割り振るということは、アプリケーション側で対応する必要があります。もし、二つのインスタンスの間で、セッション情報の共有が無い場合、次の図の様な問題が起きてしまいます。 インスタンス#1 へ要求が振られた時にログインした場合、次のリクエストでインスタンス#2 に振られると、そこには、ユーザーが認証を通過してログインした情報がありませんから、再度、ログインを要求することになります。 これでは真面な操作が出来ないですね。

この問題を解決して、アプリケーションのスケーラビリティを確保するためには、インスタンス#1 と #2 でセッション情報を共有して、どちらのインスタンスに振られた場合でも、同じセッション情報にアクセスできる様にする必要があります。

フレームワークとセッション管理

ユーザー認証を通過してログイン状態を管理すること、ユーザーが検索結果の何ページ目を参照しているか、といったセッションの情報をアプリを開発プログラマーが、一から開発するのは工数が勿体無いので、その部分は開発言語のフレームワークを活用して、効率化を図ります。

今回取り上げる Express(3) は、Node.js 用に書かれたフレームワークで、ノウハウ記事(4),(5)も豊富にあります。このフレームワークの express-session(6)(7) は、セッション管理を処理するモジュールです。 このモジュールには、たくさんのセッション・ストアのアクセスモジュール(8)が提供されており、この中から、適切なセッション・ストアのアクセスモジュールを選ぶ事ができます。

セッション・ストアとして、メジャーなものは、memcached や Redis がありますが、今回は Bluemix PaaSで、1GB未満であれば無料で利用できる Cloudandのセッション・ストアのモジュール(10) を利用してみたいと思います。

Express + Cloudant サンプルコードの解説

サンプルコードは、takara9/express-session-cloudant (9) に登録してあり、Bluemix にアカウントがあれば、すぐに実行してみる事ができます。ここでは、コードの解説を中心に書いていきます。

プログラムの先頭部分で、express フレームワークのモジュールをインストールします. expressフレームワークは、コードの構造に大きく影響を与えるので、先頭に置くことで、コードを読む人にとって、解読するヒントを与える事ができます。

app.js
 1  #!/usr/bin/env node
 2  
 3  var express = require('express');
 4  var parseurl = require('parseurl');
 5  var session = require('express-session');
 6  var app = express();

Bluemixのランタイム環境では、接続するデータベース・サービスのアドレスや認証情報を環境変数で提供します。 コードを書いてテストするなどの、開発環境は、Windows や Mac などのローカル環境が便利です。この場合、Bluemix のサービスと繋いで、コードの開発やテストを実施するケースに備えて、JSONファイルに環境変数で提供される内容を書いておき、環境に合わせた動作ができる様にしておきます。 接続先の情報を環境変数で読み取りことで、本番用DBや開発用DBなどを自動的に切り替えるので、事故を防止できるため便利です。

9行目の cfenv は、環境変数、または、JOSNファイルからサービスへアクセスする情報を設定するモジュールです。
12行目に、ローカルの開発環境用のJSONファイルを指定します。
18行目の'Cloudant NoSQL DB-zj'の部分を、Bluemix の Cloudant のサービス名に書き換えて利用します。cfenvには、複数のCloudant のサービスを利用するケースなどを想定して、サービスのインスタンス名で検索する機能を利用しているためです。

app.js
 8  // 環境変数、または、JSONファイルからCloudantの接続先を取得
 9  var cfenv = require("cfenv");
10  var vcapLocal;
11  try {
12      vcapLocal = require("./vcap-local.json");
13  } catch (err) {
14      throw err;
15  }
16  var appEnvOpts = vcapLocal ? { vcap: vcapLocal} : {}
17  var appEnv = cfenv.getAppEnv(appEnvOpts);
18  var svc = appEnv.getServiceCreds('Cloudant NoSQL DB-zj');

次は、セッション・ストアを設定する部分です。 24〜27行の中で、セッション・ストアをインスタンス化しています。 25行目の database 名は、事前にデータベースが存在している必要がありますので、(9)の資料を参考にして、事前にデータベースを作成しておいてください。 次の行の urlは サービスのアドレス、ユーザーID、パスワードをまとめた情報になります。

29行目からのコールバックは、セッション・ストア接続時に発生するイベントでコールバックがあります。

app.js
20  // セッション・ストアを設定する
21  var CloudantStore = require('connect-cloudant-store')(session);
22  console.log("credentials = ", svc.url);
23  
24  store = new CloudantStore({
25     database: 'session_express',
26      url: svc.url
27  });
28  
29  store.on('connect', function() {
30      console.log("Cloudant Session store is ready for use ");
31  });
32   
33  store.on('disconnect', function() {
34      console.log("failed to connect to cloudant db - by default falls back to MemoryStore");
35  });
36   
37  store.on('error', function(err) {
38      console.log("You can log the store errors to your app log");
39  });

次の app.use は、HTTPでアクセスがあった場合に、アプリケーションの応答処理へ至るまでの間に、中間的な処理を実行するためのミドルウェア(11)と呼ばる処理の登録です。 ここでは、セッション管理サービスを登録しています。 そして、45行目 store の値に、前述の ClandantStore のインスタンスを指定することで、Cloudant をセッション・ストアとして利用を開始します。

app.js
43  // セッション管理 ミドルウェア設定
44  var sess_opt = {
45    store: store,
46    secret: 'May the force be with you',
47    resave: false,
48    saveUninitialized: true,
49    proxy: true
50  };
51  app.use(session(sess_opt));

次のパートは、同じミドルウェアですが、URLのドメイン名より下のパスの部分ごとに、アクセス数をカウント(62行目)するものです。 最後のnext()は、次のミドルウェアやアプリ応答処理へ処理を繋げれるための指示です。

app.js
55  // アクセスカウントを計算するミドルウェア
56  app.use(function (req, res, next) {
57    var views = req.session.views;
58    if (!views) {
59      views = req.session.views = {}
60    }
61    var pathname = parseurl(req).pathname;
62    views[pathname] = (views[pathname] || 0) + 1;
63    console.log("pathname=", pathname, "times=", views[pathname]);
64    console.log("req.session.id = ", req.session.id);
65    console.log("req.session.cookie = ", req.session.cookie);
66    console.log("req.sessionID = ", req.sessionID);
67  
68    next();
69  })

次の3つのapp.getは、URL のパスに該当する部分の振る舞いを定義するものです。 /foo, /bar をアクセスすると、それぞれ、別々のカウントが増加します。 ブラウザが起動している間、同じカウンタが適用されます。 また、/ をアクセスすると、Hello ! とだけ表示されます。

app.js
72  // カウンタ表示
73  app.get('/foo', function (req, res, next) {
74    res.send('you viewed this page ' + req.session.views['/foo'] + ' times');
75  });
76  
77  app.get('/bar', function (req, res, next) {
78    res.send('you viewed this page ' + req.session.views['/bar'] + ' times');
79  });
80  
81  app.get('/', function (req, res, next) {
82    res.send('Hello !');
83  });

最後のパートが、HTTPサーバーの起動です。 Bluemix のランタイム環境では、ランタイムがリッスンするべき、ポート番号を PORT 環境変数で提供します。この環境変数のポート番号は、Bluemix の ロードバランサー (gorouter)から、ヘルスチェックの対象となり、環境変数で提供されたポート番号が リッスン状態になっていないと、アプリケーションが停止したものと判断して、強制的に再起動が実行されます。 3回繰り返して、やはり、ヘルスチェックがパスしなければ、異常停止処置となります。

環境変数が設定されてない場合、つまり、ローカル環境では、TCPポート 3000番(87行目)で、HTTPサーバーを起動します。したがって、http://localhost:3000/ でアクセスできます。

app.js
86  // HTTPサーバー
87  var port = process.env.PORT || 3000
88  app.listen(port, function() {
89      console.log("To view your app, open this link in your browser: http://localhost:" + port);
90  });

サンプルコードを利用したテスト

自分の開発環境で、サーバーを起動したり、Bluemix へデプロイするコマンドを実行する場所は、以下のディレクリです。

imac:session-cloudant maho$ ls -al
total 64
drwxr-xr-x  11 maho  staff   374  7 12 23:27 .
drwxr-xr-x  23 maho  staff   782  7 12 14:51 ..
drwxr-xr-x  13 maho  staff   442  7 12 20:51 .git
-rw-r--r--   1 maho  staff   885  7 12 20:50 .gitignore
-rw-r--r--   1 maho  staff    37  7 12 20:41 README.md
-rw-r--r--   1 maho  staff  2354  7 12 23:21 app.js
-rwxr-xr-x   1 maho  staff   815  7 12 18:45 create_db.js
-rw-r--r--   1 maho  staff   194  7 12 20:17 manifest.yml
-rw-r--r--   1 maho  staff   404  7 12 15:26 package.json
-rw-r--r--   1 maho  staff   722  7 12 18:44 vcap-local.json
-rw-r--r--   1 maho  staff   722  7 12 20:40 vcap-local.json.sample

ローカル環境での実行

このディレクトリには、npm が読み込む package.json を置いてありますから、以下のコマンドだけで、Node.js の必要なパッケージを導入できます。

imac:session-cloudant maho$ npm install

次の様に npm start を実行することで、自分のMacのターミナル内で、実行することができます。 "Cloudant Session store is ready for use" の表示が出れば、成功です。

imac:session-cloudant maho$ npm start

> [email protected] start /Users/maho/bluemix/session-cloudant
> node ./app.js

credentials =  https://deebd7c1-59df-48c6-875a-934a7adb88cc-bluemix:da99eb254e7a1513edaf3acb820bb924579c455d6d0e990c220f5b9538e3f2e8@deebd7c1-59df-48c6-875a-934a7adb88cc-bluemix.cloudant.com
To view your app, open this link in your browser: http://localhost:3000
Cloudant Session store is ready for use 

http://localhost:3000/fooをアクセスした初回は、1を表示

http://localhost:3000/fooをアクセスした2回目は、2を表示

ローカルのシングルサーバーで、正しく動作していることが解ります。

ローカル環境でのトラブルシューティング

セッション・ストアに接続できないなどの問題があり、詳細なエラーメッセージが表示されず、困る場合の対処方法です。

imac:session-cloudant maho$ npm start

> [email protected] start /Users/maho/bluemix/session-cloudant
> node ./app.js

To view your app, open this link in your browser: http://localhost:3000
failed to connect to cloudant db - by default falls back to MemoryStore

次の環境変数をセットして、再実行してください。

imac:session-cloudant maho$ export DEBUG=connect:cloudant-store

これにより、以下の様なデバックメッセージが表示される様になり、問題判別の手助けになります。

To view your app, open this link in your browser: http://localhost:3000
failed to connect to cloudant db - by default falls back to MemoryStore
  connect:cloudant-store DATABASE does not exists {"error":"unauthorized","reason":"Name or password is incorrect.","statusCode":401} +0ms

Bluemix での実行

Bluemixで実行する場合は、manifest.yml を少し修正する必要があります。 7行目 ホスト名が他のユーザーと重複すると、ドメイン名が取れないため、デプロイに失敗するので、修正します。 それから、今回のテーマは、マルチインスタンス環境下でのセッション管理ですから、4行目のインスタンス数は、2以上の値をセットします。

manifest.yml
 1  applications:
 2  - path: .
 3    memory: 128M
 4    instances: 2
 5    domain: mybluemix.net
 6    name: express-session-test
 7    host: express-session-test-tkr
 8    disk_quota: 1024M
 9  services:
10  - Cloudant NoSQL DB-zj

この状態でもデプロイをスタートできるのですが、Node.jsのソフトウェアモジュール群を転送すると通信時間がかかるので、次の様に削除しておきます。

imac:session-cloudant maho$ rm -fr node_modules

これで、Bluemixへデプロイします。

imac:session-cloudant maho$ bx cf push
'cf push' を起動しています...

マニフェスト・ファイル /Users/maho/bluemix/session-cloudant/manifest.yml を使用しています

[email protected] として組織 試験 / スペース dev 内のアプリ express-session-test を更新しています...
OK

経路 express-session-test-tkr.mybluemix.net を使用しています
express-session-test をアップロードしています...
次のパスからアプリ・ファイルをアップロードしています: /Users/maho/bluemix/session-cloudant
2.9K、6 個のファイルをアップロードしています
Done uploading         

<中略>

要求された状態: started
インスタンス: 2/2
使用: 128M x 2 インスタンス
URL: express-session-test-tkr.mybluemix.net
最終アップロード日時: Thu Jul 13 03:59:49 UTC 2017
スタック: cflinuxfs2
ビルドパック: SDK for Node.js(TM) (ibm-node.js-6.10.2, buildpack-v3.12-20170505-0656)

     状態   開始日時                 CPU    メモリー            ディスク          詳細
#0   実行   2017-07-13 01:01:06 PM   0.2%   128M の中の 61.4M   1G の中の 88.3M
#1   実行   2017-07-13 01:01:01 PM   0.2%   128M の中の 60.1M   1G の中の 88.3M

ここまでで、デプロイが完了しました。 マルチインスタンス化で、動作するか確認してみましょう。

ブラウザから Bluemix PaaS でアサインされた URL名にアクセスしてみます。そして、複数回 リロードを実行して、カウンターが増える数を確認しておきます。



ログをリストして、2つのインスタンスをまたがって、カウンターが増加している事を確かめるために、次のコマンドを実行します。 ここで、/foo をアクセスした場合、[APP/PROC/WEB/0] と [APP/PROC/WEB/1] が交互に表示され、カウントは、1個づつ加算されていることが解ります。

セッション・ストアが分断されている場合の動作

セッション・ストアが分断されている場合、(この結果では不思議とアクセスが偏っていますが)インスタンス #0, #1の其々で、カウントが進んでいるのが解ります。 これでは、アプリの正常な動作は厳しいですね。

まとめ

Bluemix でランタイム上のアプリを水平分散クラスタとして構成する実装レベルの例が、見当たらなかったので、作成しました。 CloudFoundry のマニュアルなども参照しながら調べると、奥の深さが良く解りました。 アプリ設計から考えないといけないので、大変ですが、Bluemixは、大変便利な環境だと思います。

参考資料

(1) Round-Robin Load Balancing https://docs.cloudfoundry.org/concepts/http-routing.html#round-robin
(2) Load Balancing https://github.com/cloudfoundry/gorouter#load-balancing
(3) Express 特定の意見に固執しない Node.js 向けの高速で最小限の Web フレームワーク http://expressjs.com/ja/
(4) Qiita Express http://qiita.com/tags/Express
(5) Qiita Nodejs http://qiita.com/tags/nodejs
(6) GitHub expressjs/session https://github.com/expressjs/session
(7) express-session README.mdの翻訳 http://qiita.com/MahoTakara/items/8495bbafc19859ef463b
(8) Compatible Session Stores https://github.com/expressjs/session#compatible-session-stores
(9) takara9/express-session-cloudant GitHub https://github.com/takara9/express-session-cloudant
(10) connect-cloudant-store https://www.npmjs.com/package/connect-cloudant-store
(11) Express APIリファレンス app.use([path,] callback [, callback...]) http://expressjs.com/ja/4x/api.html#app.use
(12) express-session connect-couchdb Error: Document update conflict http://qiita.com/Itomaki/items/fc864f3b5dae997fe91f
(13) Node.js + Express.js + express-sessionでセッションにデータ格納する方法 http://qiita.com/moomooya/items/00f89e425a3034b8ea14
(14) Node.js(Express) + express-sessionを利用する http://qiita.com/mazeltov7/items/39176aea0dba723fd60f#_reference-5e7bb95e19529c13f547
(15) [NodeJS] Express4でセッションを扱う http://www.yoheim.net/blog.php?q=20170208
(16) expressでアプリケーションを作る際にsessionを使う http://qiita.com/hika7719/items/3282ab2ebcdaf080912e