本番環境のベストプラクティス: パフォーマンスと信頼性

概要

この記事では、本番環境にデプロイされた Express アプリケーションのパフォーマンスと信頼性に関するベストプラクティスについて説明します。

このトピックは、従来の開発と運用両方にまたがる「DevOps」の世界に明確に分類されます。したがって、情報は2つの部分に分かれています。

コード内で実行すること

アプリケーションのパフォーマンスを向上させるために、コード内で実行できることをいくつか紹介します。

gzip圧縮を使用する

gzip 圧縮は、レスポンスボディのサイズを大幅に縮小し、それによって Web アプリの速度を向上させることができます。Express アプリで gzip 圧縮を行うには、compression ミドルウェアを使用します。例:

const compression = require('compression')
const express = require('express')
const app = express()
app.use(compression())

本番環境の高トラフィック Web サイトでは、圧縮を実装する最良の方法は、リバースプロキシレベルで実装することです(「リバースプロキシを使用する」を参照)。その場合、圧縮ミドルウェアを使用する必要はありません。Nginx での gzip 圧縮の有効化の詳細については、Nginx ドキュメントの Module ngx_http_gzip_module を参照してください。

同期関数を使用しない

同期関数とメソッドは、それらが戻るまで実行中のプロセスを拘束します。同期関数への1回の呼び出しは、数マイクロ秒または数ミリ秒で戻る可能性がありますが、高トラフィックの Web サイトでは、これらの呼び出しが積み重なり、アプリのパフォーマンスが低下します。本番環境での使用は避けてください。

Nodeと多くのモジュールは、関数の同期バージョンと非同期バージョンを提供していますが、本番環境では常に非同期バージョンを使用してください。同期関数が正当化される唯一の時は、初期起動時です。

Node.js 4.0以降またはio.js 2.1.0以降を使用している場合は、--trace-sync-ioコマンドラインフラグを使用して、アプリケーションが同期APIを使用するたびに警告とスタックトレースを印刷できます。もちろん、これを本番環境で使用したくないでしょう。むしろ、コードが本番環境に対応していることを確認するために使用します。詳細については、nodeコマンドラインオプションドキュメントを参照してください。

ロギングを正しく行う

一般的に、アプリからロギングを行う理由は2つあります。デバッグとアプリのアクティビティのロギング(基本的に、それ以外のすべて)です。ログメッセージをターミナルに出力するためにconsole.log()またはconsole.error()を使用することは、開発では一般的な方法です。しかし、出力先がターミナルまたはファイルの場合、これらの関数は同期であるため、出力を別のプログラムにパイプしない限り、本番環境には適していません。

デバッグ用

デバッグのためにロギングしている場合は、console.log()を使用する代わりに、debugのような特別なデバッグモジュールを使用します。このモジュールを使用すると、DEBUG環境変数を使用して、console.error()に送信されるデバッグメッセージ(ある場合)を制御できます。アプリを純粋に非同期に保つためには、console.error()を別のプログラムにパイプする必要があります。しかし、本番環境でデバッグすることはないでしょう。

アプリのアクティビティ用

アプリのアクティビティ(トラフィックやAPI呼び出しの追跡など)をロギングしている場合は、console.log()を使用する代わりに、WinstonBunyanのようなロギングライブラリを使用します。これら2つのライブラリの詳細な比較については、StrongLoopブログの投稿「Comparing Winston and Bunyan Node.js Logging」を参照してください。

例外を適切に処理する

Nodeアプリは、キャッチされない例外が発生するとクラッシュします。例外を処理せず、適切なアクションを実行しないと、Expressアプリがクラッシュしてオフラインになります。アプリが自動的に再起動するようにするで説明されているアドバイスに従うと、アプリはクラッシュから回復します。幸い、Expressアプリは通常、起動時間が短いです。それにもかかわらず、まずクラッシュを回避したいので、例外を適切に処理する必要があります。

すべての例外を処理するには、次の手法を使用します。

これらのトピックに入る前に、Node/Express のエラー処理の基本的な理解、つまり、エラーファーストのコールバックの使用と、ミドルウェアでのエラーの伝播が必要です。Node は、非同期関数からエラーを返すために「エラーファーストコールバック」規約を使用します。コールバック関数の最初のパラメータはエラーオブジェクトであり、後続のパラメータは結果データです。エラーがないことを示すには、最初のパラメータとして null を渡します。コールバック関数は、エラーを意味のある形で処理するために、対応するエラーファーストのコールバック規約に従う必要があります。また、Express では、ベストプラクティスは、next() 関数を使用してミドルウェアチェーンを通じてエラーを伝播することです。

エラー処理の基礎の詳細については、次を参照してください。

してはいけないこと

しないことの1つは、例外がイベントループまでバブルアップしたときに発生する uncaughtException イベントをリッスンすることです。uncaughtException のイベントリスナーを追加すると、例外が発生しているプロセスのデフォルトの動作が変更されます。例外にもかかわらず、プロセスは実行を継続します。これは、アプリのクラッシュを防ぐための良い方法のように聞こえるかもしれませんが、キャッチされない例外が発生した後にアプリの実行を継続することは危険な行為であり、お勧めしません。プロセスの状態が信頼できなくなり、予測不可能になるためです。

さらに、uncaughtExceptionを使用することは公式に粗悪と認識されています。したがって、uncaughtExceptionをリッスンすることは単に悪い考えです。そのため、複数のプロセスとスーパーバイザーのようなものを推奨します。クラッシュして再起動することは、多くの場合、エラーから回復するための最も信頼性の高い方法です。

ドメインの使用も推奨しません。通常、問題は解決せず、非推奨のモジュールです。

try-catch を使用する

try-catch は、JavaScript 言語の構文で、同期コードの例外をキャッチするために使用できます。たとえば、以下に示すように JSON パースエラーを処理するために try-catch を使用します。

JSHintJSLint などのツールを使用して、未定義の変数に対する参照エラーのような暗黙的な例外を見つけるのに役立ててください。

以下は、try-catch を使用して潜在的なプロセス クラッシュ例外を処理する例です。このミドルウェア関数は、JSON オブジェクトである「params」という名前のクエリ フィールド パラメータを受け入れます。

app.get('/search', (req, res) => {
  // Simulating async operation
  setImmediate(() => {
    const jsonStr = req.query.params
    try {
      const jsonObj = JSON.parse(jsonStr)
      res.send('Success')
    } catch (e) {
      res.status(400).send('Invalid JSON string')
    }
  })
})

ただし、try-catch は同期コードでのみ機能します。Node プラットフォームは主に非同期であるため(特に本番環境では)、try-catch は多くの例外をキャッチしません。

Promise を使用する

Promise は、then() を使用する非同期コードブロック内の例外(明示的および暗黙的の両方)を処理します。Promise チェーンの最後に .catch(next) を追加するだけです。例:

app.get('/', (req, res, next) => {
  // do some sync stuff
  queryDb()
    .then((data) => makeCsv(data)) // handle data
    .then((csv) => { /* handle csv */ })
    .catch(next)
})

app.use((err, req, res, next) => {
  // handle error
})

これで、すべての非同期および同期エラーがエラーミドルウェアに伝播されます。

ただし、2つの注意点があります。

  1. すべての非同期コードはPromiseを返す必要があります(エミッタを除く)。特定のライブラリがPromiseを返さない場合は、Bluebird.promisifyAll()のようなヘルパー関数を使用して基本オブジェクトを変換します。
  2. イベントエミッタ(ストリームなど)は、まだキャッチされない例外を引き起こす可能性があります。したがって、エラーイベントを適切に処理していることを確認してください。例:
const wrap = fn => (...args) => fn(...args).catch(args[2])

app.get('/', wrap(async (req, res, next) => {
  const company = await getCompanyById(req.query.id)
  const stream = getLogoStreamById(company.id)
  stream.on('error', next).pipe(res)
}))

wrap() 関数は、拒否されたPromiseをキャッチし、最初の引数としてエラーを使用してnext()を呼び出すラッパーです。詳細については、「Promise、Generator、ES7 による Express での非同期エラー処理」を参照してください。

Promise を使用したエラー処理の詳細については、「Node.js での Promise と Q – コールバックの代替」を参照してください。

環境/セットアップで実行すること

アプリのパフォーマンスを向上させるために、システム環境で実行できることをいくつか紹介します。

NODE_ENV を「production」に設定する

NODE_ENV 環境変数は、アプリケーションが実行されている環境(通常は、開発環境または本番環境)を指定します。パフォーマンスを向上させるために実行できる最も簡単なことの1つは、NODE_ENV を「production」に設定することです。

NODE_ENV を「production」に設定すると、Express は次のようになります。

テストの結果、これを行うだけで、アプリのパフォーマンスを3倍に向上させることができます!

環境固有のコードを記述する必要がある場合は、process.env.NODE_ENVを使用してNODE_ENVの値を確認できます。環境変数の値をチェックするとパフォーマンスの低下が発生するため、控えめに行う必要があることに注意してください。

開発では、通常、インタラクティブシェルで環境変数を設定します。たとえば、exportまたは.bash_profileファイルを使用します。しかし、一般的に、本番サーバーでそれを行うべきではありません。代わりに、OSのinitシステム(systemdまたはUpstart)を使用してください。次のセクションでは、initシステムを全般的に使用する方法の詳細について説明しますが、NODE_ENVの設定はパフォーマンスにとって非常に重要であるため(実行も簡単)、ここで強調されています。

Upstartでは、ジョブファイルでenvキーワードを使用します。例:

# /etc/init/env.conf
 env NODE_ENV=production

詳細については、「Upstart の概要、クックブック、ベストプラクティス」を参照してください。

systemd では、ユニットファイルで Environment ディレクティブを使用します。例:

# /etc/systemd/system/myservice.service
Environment=NODE_ENV=production

詳細については、「systemd ユニットでの環境変数の使用」を参照してください。

アプリが自動的に再起動することを確認する

本番環境では、アプリケーションがオフラインになることを望みません。つまり、アプリがクラッシュした場合と、サーバー自体がクラッシュした場合の両方で、アプリが再起動することを確認する必要があります。これらのイベントが発生しないことを期待しますが、現実的には、次の両方を考慮する必要があります。

Nodeアプリケーションは、キャッチされない例外が発生するとクラッシュします。まず最も重要なことは、アプリケーションが十分にテストされ、すべての例外を処理するようにすることです(詳細は例外の適切な処理を参照)。しかし、フェイルセーフとして、アプリケーションがクラッシュした場合に自動的に再起動するメカニズムを導入してください。

プロセス管理ツールを使用する

開発環境では、node server.jsなどのコマンドラインから単純にアプリケーションを起動していたでしょう。しかし、これを本番環境で行うのは災いの元です。アプリケーションがクラッシュすると、再起動するまでオフラインになります。アプリケーションがクラッシュした場合に再起動を確実にするには、プロセス管理ツールを使用してください。プロセス管理ツールは、デプロイを容易にし、高可用性を提供し、実行時にアプリケーションを管理できるようにする、アプリケーションの「コンテナ」です。

プロセス管理ツールは、アプリケーションのクラッシュ時の再起動に加えて、以下も可能にします。

Nodeで最も人気のあるプロセス管理ツールは次のとおりです。

3つのプロセス管理ツールの機能比較については、http://strong-pm.io/compare/を参照してください。3つすべてについてのより詳しい紹介は、Expressアプリのプロセス管理ツールを参照してください。

これらのプロセス管理ツールのいずれかを使用すれば、アプリケーションが時々クラッシュしても、稼働状態を維持できます。

ただし、StrongLoop PMには、本番環境でのデプロイを特にターゲットとした多くの機能があります。これと関連するStrongLoopツールを使用すると、以下のことが可能です。

以下で説明するように、StrongLoop PMをinitシステムを使用してオペレーティングシステムサービスとしてインストールすると、システムが再起動したときに自動的に再起動します。したがって、アプリケーションプロセスとクラスタは永続的に稼働し続けます。

initシステムを使用する

信頼性の次の層は、サーバーが再起動したときにアプリケーションが再起動されるようにすることです。システムはさまざまな理由で停止する可能性があります。サーバーがクラッシュした場合にアプリケーションが再起動されるようにするには、OSに組み込まれているinitシステムを使用してください。現在使用されている2つの主要なinitシステムは、systemdUpstartです。

Expressアプリでinitシステムを使用するには、次の2つの方法があります。

Systemd

Systemdは、Linuxシステムおよびサービスマネージャーです。ほとんどの主要なLinuxディストリビューションは、systemdをデフォルトのinitシステムとして採用しています。

systemdサービス構成ファイルは、ユニットファイルと呼ばれ、ファイル名の末尾が.serviceです。Nodeアプリケーションを直接管理するためのユニットファイルの例を次に示します。<山括弧>で囲まれた値をシステムとアプリケーションに合わせて置き換えてください。

[Unit]
Description=<Awesome Express App>

[Service]
Type=simple
ExecStart=/usr/local/bin/node </projects/myapp/index.js>
WorkingDirectory=</projects/myapp>

User=nobody
Group=nogroup

# Environment variables:
Environment=NODE_ENV=production

# Allow many incoming connections
LimitNOFILE=infinity

# Allow core dumps for debugging
LimitCORE=infinity

StandardInput=null
StandardOutput=syslog
StandardError=syslog
Restart=always

[Install]
WantedBy=multi-user.target

systemdの詳細については、systemdリファレンス(manページ)を参照してください。

systemdサービスとしてのStrongLoop PM

StrongLoop Process Managerをsystemdサービスとして簡単にインストールできます。インストール後、サーバーが再起動すると、StrongLoop PMが自動的に再起動し、管理しているすべてのアプリケーションが再起動します。

StrongLoop PMをsystemdサービスとしてインストールするには

$ sudo sl-pm-install --systemd

次に、次のコマンドでサービスを開始します。

$ sudo /usr/bin/systemctl start strong-pm

詳細については、本番ホストの設定(StrongLoopドキュメント)を参照してください。

Upstart

Upstartは、システムの起動中にタスクとサービスを開始し、シャットダウン中に停止し、それらを監視するために、多くのLinuxディストリビューションで利用可能なシステムツールです。Expressアプリまたはプロセス管理ツールをサービスとして構成すると、Upstartはクラッシュ時に自動的に再起動します。

Upstartサービスは、ジョブ構成ファイル(「ジョブ」とも呼ばれます)で定義され、ファイル名の末尾が.confです。次の例は、メインファイルが/projects/myapp/index.jsにある「myapp」という名前のアプリケーションに対して、「myapp」というジョブを作成する方法を示しています。

/etc/init/myapp.confという名前のファイルを作成し、次の内容を入力します(太字のテキストをシステムとアプリケーションの値で置き換えてください)。

# When to start the process
start on runlevel [2345]

# When to stop the process
stop on runlevel [016]

# Increase file descriptor limit to be able to handle more requests
limit nofile 50000 50000

# Use production mode
env NODE_ENV=production

# Run as www-data
setuid www-data
setgid www-data

# Run from inside the app dir
chdir /projects/myapp

# The process to start
exec /usr/local/bin/node /projects/myapp/index.js

# Restart the process if it is down
respawn

# Limit restart attempt to 10 times within 10 seconds
respawn limit 10 10

注:このスクリプトには、Ubuntu 12.04〜14.10でサポートされているUpstart 1.4以降が必要です。

システム起動時に実行するようにジョブが構成されているため、アプリケーションはオペレーティングシステムとともに起動し、アプリケーションがクラッシュした場合やシステムがダウンした場合に自動的に再起動されます。

Upstartは、アプリケーションの自動再起動に加えて、次のコマンドを使用できます。

Upstartの詳細については、Upstartの紹介、クックブック、およびベストプラクティスを参照してください。

UpstartサービスとしてのStrongLoop PM

StrongLoop Process ManagerをUpstartサービスとして簡単にインストールできます。インストール後、サーバーが再起動すると、StrongLoop PMが自動的に再起動し、管理しているすべてのアプリケーションが再起動します。

StrongLoop PMをUpstart 1.4サービスとしてインストールするには

$ sudo sl-pm-install

次に、次のコマンドでサービスを実行します。

$ sudo /sbin/initctl start strong-pm

注:Upstart 1.4をサポートしていないシステムでは、コマンドが若干異なります。詳細については、本番ホストの設定(StrongLoopドキュメント)を参照してください。

クラスターでアプリを実行する

マルチコアシステムでは、プロセスのクラスタを起動することにより、Nodeアプリケーションのパフォーマンスを何倍にも向上させることができます。クラスタは、アプリケーションの複数のインスタンスを(理想的には各CPUコアに1つのインスタンス)実行し、それによってインスタンス間で負荷とタスクを分散します。

Balancing between application instances using the cluster API

重要:アプリケーションインスタンスは個別のプロセスとして実行されるため、同じメモリ空間を共有しません。つまり、オブジェクトはアプリケーションの各インスタンスに対してローカルです。したがって、アプリケーションコードで状態を維持することはできません。ただし、Redisのようなインメモリデータストアを使用して、セッション関連のデータと状態を保存できます。この注意点は、複数のプロセスを使用したクラスタリングであろうと、複数の物理サーバーであろうと、基本的にすべての形式の水平スケーリングに適用されます。

クラスタ化されたアプリケーションでは、ワーカプロセスは、残りのプロセスに影響を与えることなく個別にクラッシュする可能性があります。パフォーマンス上の利点に加えて、障害の分離もアプリケーションプロセスのクラスタを実行するもう1つの理由です。ワーカプロセスがクラッシュしたときは常に、イベントをログに記録し、cluster.fork()を使用して新しいプロセスを生成するようにしてください。

Nodeのclusterモジュールを使用する

クラスタリングは、Nodeのclusterモジュールによって可能になります。これにより、マスタープロセスがワーカプロセスを生成し、ワーカ間で着信接続を分散できるようになります。ただし、このモジュールを直接使用するのではなく、自動的に実行してくれる多くのツール(たとえば、node-pmcluster-serviceなど)の1つを使用する方がはるかに優れています。

StrongLoop PMを使用する

StrongLoop Process Manager(PM)にアプリケーションをデプロイすると、アプリケーションコードを変更せずにクラスタリングを利用できます。

StrongLoop Process Manager(PM)がアプリケーションを実行すると、システムのCPUコア数と同じ数のワーカを持つクラスタで自動的に実行されます。slcコマンドラインツールを使用して、アプリケーションを停止せずに、クラスタ内のワーカプロセスの数を手動で変更できます。

たとえば、アプリケーションをprod.foo.comにデプロイし、StrongLoop PMがポート8701(デフォルト)でリッスンしていると仮定すると、slcを使用してクラスタサイズを8に設定するには、次のようにします。

$ slc ctl -C http://prod.foo.com:8701 set-size my-app 8

StrongLoop PMでのクラスタリングの詳細については、StrongLoopドキュメントのクラスタリングを参照してください。

PM2を使用する

PM2でアプリケーションをデプロイすると、アプリケーションコードを変更せずにクラスタリングを利用できます。まず、アプリケーションがステートレスであることを確認する必要があります。つまり、プロセスにローカルデータ(セッション、WebSocket接続など)が保存されていないということです。

PM2でアプリケーションを実行する場合、クラスタモードを有効にして、マシン上の使用可能なCPU数に一致するなど、選択したインスタンス数でクラスタで実行できます。pm2コマンドラインツールを使用して、アプリケーションを停止せずに、クラスタ内のプロセス数を手動で変更できます。

クラスタモードを有効にするには、次のようにアプリケーションを起動します。

# Start 4 worker processes
$ pm2 start npm --name my-app -i 4 -- start
# Auto-detect number of available CPUs and start that many worker processes
$ pm2 start npm --name my-app -i max -- start

これは、exec_modeclusterに、instancesを開始するワーカ数に設定することで、PM2プロセスファイル(ecosystem.config.jsなど)内でも構成できます。

一度実行すると、アプリケーションは次のようにスケーリングできます。

# Add 3 more workers
$ pm2 scale my-app +3
# Scale to a specific number of workers
$ pm2 scale my-app 2

PM2でのクラスタリングの詳細については、PM2ドキュメントのクラスタモードを参照してください。

リクエスト結果をキャッシュする

本番環境でのパフォーマンスを向上させるもう1つの戦略は、リクエストの結果をキャッシュすることです。これにより、アプリケーションは同じリクエストを繰り返し処理する必要がなくなります。

VarnishNginxNginxキャッシングも参照)のようなキャッシュサーバーを使用して、アプリケーションの速度とパフォーマンスを大幅に向上させます。

ロードバランサーを使用する

アプリケーションがどれほど最適化されていても、単一のインスタンスで処理できる負荷とトラフィックには限りがあります。アプリケーションをスケーリングする方法の1つは、その複数のインスタンスを実行し、ロードバランサーを介してトラフィックを分散することです。ロードバランサーを設定すると、アプリケーションのパフォーマンスと速度が向上し、単一インスタンスでは不可能なほどスケールできます。

ロードバランサーは通常、複数のアプリケーションインスタンスやサーバーとの間のトラフィックを調整するリバースプロキシです。NginxHAProxyを使用することで、アプリケーション用のロードバランサーを簡単に設定できます。

ロードバランシングでは、特定のセッションIDに関連付けられたリクエストが、それらを生成したプロセスに接続されるようにする必要がある場合があります。これはセッションアフィニティまたはスティッキーセッションとして知られており、(アプリケーションに応じて)セッションデータにRedisのようなデータストアを使用するという上記提案で対処できる場合があります。詳しくは複数のノードの使用を参照してください。

リバースプロキシを使用する

リバースプロキシは、Webアプリの前に配置され、リクエストをアプリに転送する以外に、リクエストに対するサポート操作を実行します。エラーページ、圧縮、キャッシュ、ファイルの提供、およびロードバランシングなどを処理できます。

アプリケーションの状態に関する知識を必要としないタスクをリバースプロキシに委ねることで、Expressは専門的なアプリケーションタスクを実行できるようになります。このため、本番環境ではNginxHAProxyのようなリバースプロキシの背後でExpressを実行することをお勧めします。