スマホアメーバにおけるNode.jsバージョンアップ作業

こんにちは、サイバーエージェントでフロントエンド開発をしています菅原(@ryo_suga)です。

Amebaには数多のサービスが存在しているのですが、その中でも2016年の11月にNode.jsのバージョンをv0.10からLTSにバージョンを上げたスマートフォン向けブラウザサービスのAmeba( https://s.amebame.com )についてご紹介します。
バージョンアップに至る経緯、途中衝突した壁や移行方法、課題についてお話します。

バージョンアップの経緯

Amebaのソーシャルプラットフォームは社内的に見てもNode.jsを使ったプロダクトとしてはかなり初期のものとしてリリースされました。
その頃のNode.jsも今と比べると未成熟でした。Node.jsのバージョンもまだv0.xで、2,3年前に一度v0.10へバージョンを少しあげてから今回のアップデートまで停滞したままでした。
そうするうちに、Node.jsはLTSというリリースサイクルに乗ることとなり、当時のバージョンであるv0.10についてはすぐにメンテナンスからもはずれてしまうという事態になりました。

古いシステムのまま運用し続けるという選択肢もあるとは思いますが、新しい仕様やバグ修正が頻繁に現れるNode.jsという生態系においては古いバージョンのままにしておくのは大きなリスクなり技術的負債の要因にもなりかねません。
またこのNode.jsのバージョンが停滞したままという状態はそのままプロジェクトにも影響を与えており、メンバーの技術ベースの向上を妨げる原因にもなりつつありました。
(他にも同一コード内に複数プロジェクトを持っていたりした歴史もあり、管理の所在がわからず変更を加えにくいコードも存在していたりしました)

そこで、システムの健全化を目標としてNode.jsのバージョンをv0.10から一気にLTS(v6.x)へ上げることにしました。

道のり

作業開始からリリースまでの流れはこのようになっています。

  • Node.js, 依存パッケージのバージョン調査
  • エラーの確認
  • テスト
  • リリース

Node.js, 依存パッケージのバージョン調査

今回Node.jsのバージョンは v0.10 ~ v6 に上がったのでそれに伴うNode.js自体の変更。
加えて依存しているパッケージのアップデートを行いました。
基本的にはpatchのみのアップデートはすぐに上げ、minorのアップデートは一応破壊的な変更がされていないか調べてからアップデート。
majorアップデートに関しては基本的に後方互換がなくなっている可能性があるので差分調査した後、影響範囲を考えつつアップデートしました。

# おなじみのコマンドで調査
$ npm outdated
Package          Current  Wanted  Latest  Location
express           4.10.6  4.14.1  4.14.1  amebame
multer             0.1.7   1.1.0   1.1.0  amebame
request           2.30.0  2.72.0  2.72.0  amebame

この時 CHANGE_LOG.md のように変更履歴にないケースがあるので、そういう時はGitHubでCompareViewを使うと良いです。

CompareView: https://github.com/$user/$repo/[commitId|tag|branch]...[commitId|tag|branch]

個人的には時間がかかってもコードベースでも、使用しているAPIの変更を追うことは有意義かなと思います。

エラーの確認

単純に上げただけでは起動しないことが多いので、ログベースでのエラーの確認をしました。
しばらくバージョンを上げていないとdeprecatedのログが頻発していることが結構な物量で存在しますが、膨大なコード量でも気合で修正します。
バージョン差分が原因で大きく修正した変更をすこしだけお伝えしようと思います。

http.Agentのデフォルトの挙動の変更

v0.10.x: The current HTTP Agent also defaults client requests to using Connection:keep-alive. ※1

v4.x: opiton.keepAlive Keep sockets around in a pool to be used by other requests in the future. Default = false ※2

このようにkeepAliveがデフォルトでfalseになっていることがわかります。
今回の場合requestを使い実装している内製のHttpClientが影響を受け、大量のリクエストを捌けなくなりました。
また, 当時のrequestのバージョンでkeep-alive時にメモリリークが頻発してしまっていました。
それを受けて修正プルリクエスト(request/request#2447)がマージされるまでリリースを待つなどの判断もありました。

const request = require('request').defaults({
  forever: true
})

requestでのkeepAlive設定

パッケージのmajorアップデート

パッケージのmajorアップデートはたいていAPIのインタフェースが多く変更されているのでそのままだと動作しません。
例えばmultipart/form-dataのミドルウェアであるmulterもmajorアップデートによって破壊的な変更がされていました。

今までexpressアプリケーションレベルでミドルウェアを使用していたのをルーターレベルで使用するように変更するのは、対象のエンドポイントの数分変更するので骨が折れました。

var express = require('express');
var app = express();
var multer = require('multer');

app.use(multer());
        ^ ここでエラー。APIのインタフェースが変わっている。
        | TypeError: app.use() requires middleware functions

このような細かいインタフェースの違いをパッケージの数分キャッチアップする作業は気合と根気を十分に蓄えた状態で臨むと良いと思います。

deprecatedログ

同様にdeprecatedのログもリリース後にログを圧迫する可能性があるので削除します。

例を挙げるとexpressのreq.paramメソッドがdeprecatedになっています。
こちら非常に高い頻度で使われているようだったのではっきりと書き換えられるところはreq.[query|params|body]に書き換えつつ、補いきれない部分は例外的にユーティリティに作って補いました。

exports.param = function(req, name, defaultValue) {
    var params = req.params || {};
    var body = req.body || {};
    var query = req.query || {};
    if (params[name] != null && params.hasOwnProperty(name)) return params[name];
    if (body[name] != null) return body[name];
    if (query[name] != null) return query[name];
    return defaultValue;
};

こういった変更は5年も運用していると物量で攻めてくるので心を折らさないように一つずつ丁寧に対応していきます。

テスト

もともとプロジェクトにテストコードは存在するにはするのですが、私がチームに参加したときはその文化がありませんでした。またコード量も膨大なものに増えてしまったためすべてのコードをカバーするのが難しい状況でした。
なので、存在するテスト以外の範囲のコードに関してはfrisbyjsを用いてAPIのエンドポイントのテストをすることにしました。
エンドポイントごとにレスポンスのインタフェースが変わらない = APIサーバーとしての差分が埋められた状態でリリースができるということとしました。

const frisby = require('frisby');
const recommendTypes = {
  items: Array,
  rule: Number,
  complemented: Boolean,
  group: Number
};
frisby.create(`GET ${recommend_api}`)
  .addHeaders({ 'User-Agent': 'some user agent you want to test' })
  .get(base + recommend_api)
  .expectStatus(200)
  .expectHeaderContains('content-type', 'application/json')
  .expectJSONTypes(recommendTypes)
.toss();

このようにエンドポイントのインタフェースが簡単にテストができるので、develop, staging, 検証環境それぞれにおいてテストを走らせることでレスポンスを保証しました。

リリース

本番へのリリースは以下のように行いました。

  • 本番1台で試験稼働
  • 全サーバーの1/3ずつリプレイスして並行稼動

諸事情により負荷試験が難しい状況だったので、まず少ないアクセスで本番稼働をさせて様子を見る必要がありました。
そのため一度にリリースせず、少しずつアクセス数を増やしてサーバーの様子を見ました。
また切り戻す際にもインフラ構成上すぐに戻すことが難しかったので並行稼動させて問題があれば外すという方針で進めてなるべくリスクを回避しようという意図もあります。

こうして特にこれといった問題にも直面せずに無事リリースを完遂することが出来ました。

まとめ・継続課題

Node.jsのバージョンを上げるだけだと特に大変ではありません。付随する依存物の影響範囲が広くなればなるほど大変になってきます。
今回のバージョンアップ作業では、コードをv0.10ベースで書いていたものが動かなくなったものだけ書き換えたので特にv6からサポートされるなES2015ベースのコードにするリファクタリングは特に行っていません。
なのでパフォーマンス的課題も、コールバック地獄になっているコードも未だ残ってしまっています。
しかしバージョンアップは細かい粒度でやると楽になります。
Node.jsのバージョンを上げたおかげで古いままシステムが放置されること無く健全に技術的負債を解消しつつ運用していくための足がかりにはなりました。
これだけでも今回のバージョンアップには大きな意味があったのだと思っています。
こちら (別プロジェクトで行われたバージョンアップ)の記事にも書かれているのですが、一気にモダンなJavaScriptが書けるようになったのでプロジェクトメンバーが新しい技術に対する感度を上げたりスキルの向上につながり全体の技術力の向上にもつながるきっかけにすることが出来ました。

今後も古いコードや新しいコードをES2015ベースで書けるようにチームに浸透させたり、技術的負債を継続的に解消してく体制を課題にプロダクトの品質とチームの技術力の向上に貢献していければと思います。