エンジニアリンググループの矢崎(id:Saiya)です。年末まで M3 USA に出張しております。
昨日は NY の地元の NBA チーム New York Knicks と Portland Trail Blazers の試合を仕事後に観戦してきました。
抜きつ抜かれつつのかなりの接戦が最後まで続き、特に終盤は地元民の盛り上がりも半端ではなく、非常に印象に残る良い体験でした。観客の参加度合いが高く、声掛けやリアクション等の一体感も相当なものでした。
また技術面では、あちこちに入っている液晶パネル、開幕演出で使われるプロジェクション、気球ドローンからの撮影 *1、T シャツを打ち出すメガ空気砲 *2など、さまざまなテクノロジーが使われている点が印象的でした。個々の技術そのものは普通の技術なのですが、技術が浮くことなく、エンターテインメントとしての一貫した流れのなかで自然に使われている点に本場のレベルの高さを感じました。
ともあれ、そのように余暇も楽しみつつも Headless CMS Contentful の導入 にあたり編集部向けの運用ツールを試作・整備しているのですが、今後のための知見獲得も兼ねて、プロトタイピングの合間に以下の技術スタックを試しています:
- Server: node.js
- Web app framework: zeit/micro -> express.js
- View framework: next.js (React) + TypeScript
- Environment: AWS ECS (Docker) *3
SPA 側とサーバーサイドのロジックに共通点がある点 *4、サーバーサイドレンダリングも可能性として考慮している点、それにスキルセットや利用ライブラリなどを共通化したい点があるため、サーバーサイドを node.js で作りつつあります。
そういった JS のサーバーサイド整備する過程でケアしたほうが良い点、悩ましい点などがいくつか見えてきたため書いてみました。同じようなことで悩んでいる方の参考になれば幸いです。ただし試行錯誤中なので、クリアな解が見えているわけではないです。マサカリを投げる場合はやさしく投げてください。
ロギング
当たり前ではありますが、そもそもロギングライブラリを入れてはおかないとログの処理・解析で大変苦労するので予め入れておきましょう。
また console.log
などは同期 I/O になってしまいます*5し、ログの整形機能なども当然ないので、ログ用のライブラリを使う方が良いはずです。
JS / node.js 向けのロギングライブラリは沢山あり悩ましいのですが、試しに pino を使いはじめてみました。
良さそうな点:
- ログの生成、フォーマット、転送のコンポーネントが分離されている
- JSON 出力と pritty print の両方が無理なくサポートされている
- ログの出力先ツールを切り替えたりがやりやすいよう配慮されている
- パフォーマンスが良い *6、少なくとも強く意識されている
- 非同期ログ出力も可能
見る限りロギングライブラリのインタフェースはライブラリによってバラバラなので、今後別のライブラリを入れたくなったときに備えて自前のインタフェースでラップしております。
終了シグナルへの対応
AWS ECS に限らず真っ当に manage されている環境では、サーバープロセスを終了するために シグナル が送られてくることがありますので、そのときにはプロセスを正しく終了することが望ましいです。例えば AWS ECS のスケールイン(コンテナ群の規模の縮小)やデプロイ時のコンテナの入れ替えなどでは、いきなりコンテナが強制終了するのではなく終了シグナルが送られてきます。
node.js / express.js はデフォルトではシグナル受信時にプロセスを即座に終了してしまうので、クリーンに終了するためには自前でシグナル受信時の処理を書くことになります:
for (const signal of [ 'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGABRT', 'SIGTERM', ] as NodeJS.Signals[]) { process.on(signal, () => { logger.info(`Closing server (${signal})...`); // リクエストの新規受付を停止し、処理中のリクエストが完了するまで待ってからサーバー自体を終了する httpServer.close(() => { logger.info("Server closed."); process.exit(0); }); // 自前でやっているバックグラウンド処理などの終了処理も必要なら足す }); }
このようにすることでサーバー自体や自前でやっているバックグラウンド処理などをクリーンに終了できます。
なお、server.close()
によってリクエストの新規受付を停止してしまうと、それ以降にロードバランサーから到来するリクエストがエラーになる点が気になるかもしれません。しかし ECS のようにロードバランサーの設定も managed な環境では、コンテナに終了シグナルが送られてくる時点でロードバランサーからも切り離されている*7のでその点は問題ではないと考えています。そうでない環境では、シグナル受信からしばらくの間は healthcheck だけエラーを返して LB からの切り離しを促しその後に close() する、といった実装が必要でしょう。
プロセスをシェル経由で起動しない
node.js 開発では node
を直接起動せず、package.json の scripts に node を起動するスクリプトを書き yarn
や npm
コマンド経由で起動することが少なくないと思います。しかしその場合、node が直接起動されるのではなくシェル経由で実行されます。結果として SIGTERM
などのシグナルがシェルに吸収されてしまい、Docker コンテナのプロセス(yarn
, npm
)に終了シグナルが来ても node
自体に終了シグナルが届かないという結果になります。
なので 参考サイト で言及されているように、Docker 環境で実行する際には node
を直接起動することでシグナルを受け取れるようにしました:
CMD [ "node", ".next/production-server/server/index.js" ]
なお、以下のように書いてしまうと、Docker によってシェル経由で起動されてしまうのでやはり同じ問題になってしまいます。exec 形式(上記の形式)で書きましょう。
# この書き方だと、シェル経由で起動する # シェルに SIGTERM シグナルなどが吸い込まれてしまう CMD node .next/production-server/server/index.js
micro -> express.js
単純な API (microservice)には zeit micro はそのミニマルさや API の綺麗さが素晴らしいです。
しかし、今回作っているコンテンツ管理ツールではブラウザ向けの画面の実装に伴うセッション管理や OAuth2 連携などがあり、micro はそのような用途にいまいちフィットしないことがプロトタイピングの結果感じられました:
- micro の標準機能では redirect などがサポートされていない (micro-redirect を使えば可能)
- awesome-micro にある micro-cookie-session *8 を使ってみようとしたが、うまくいかず
- micro-redirect と併用するとセッションがうまく作られない
- micro 本体, micro-redirect, micro-cookie-session の 3 者の相性がなにか悪いのだと思われる
- micro は HTTP response status と response body と body の変換処理(JSON 変換など)を
send
といったメソッド一つでまとめて行う思想- 全 Controller に共通に適用するラッパー(middleware)がレスポンス処理の一部に介入する、という設計がやりにくい
上記のような事象を踏んでしまったこともあり、プロトタイピング過程で micro から express.js に切り替えました。
ほとんどの画面の処理は next.js で書いており、また web framework 間で request, response 型のインタフェースが大きく異なるわけではないので、切り替えのインパクトはそれほどではなかったです。今後のプロトタイピング過程でもしかしたら Connect なども試してみるかもしれません。
next.js
まだあまり使い込めていないですが、現時点では以下の課題に対してクリアな解決法を見いだせていない状態です:
- ビルドが普通の create-react-app アプリなどと比べると遅い *9
- ビルドしたサーバーサイドのコードが吐き出すスタックトレースに情報がほぼなく、サーバー側のデバッグが念力依存になってしまっている
- ドキュメントや GitHub issues, Stackoverflow をさっと探した限り、minify を解除する方法が不明
- 時間をかけて next.js のデフォルトのビルド処理 を解析すれば改善できるかもしれないが、労力面と今後の保守の懸念で二の足を踏んでいる状態...
- 循環を含むオブジェクトが initial props に入る場合にシリアライズできずにエラーになってしまう問題
- 例えば Contentful の公式 SDK が返すオブジェクト *10
- 自力でシリアライズしやすい形のオブジェクトに変換するように頑張るしかない
サーバーサイドレンダリングが必要になった際にサッと導入できる点*11に魅力を感じて next.js にトライしたのですが、今後本格的に利用するかは悩ましいところです。
async (Promise) のスタックトレース
node.js といえば非同期処理、JS の非同期処理といえば async (Promise)ですが、非同期処理でエラーになった際にスタックトレースがあまり取れないため、エラー調査やデバッグ面では辛いときがあります。
今の所、先月に V8 に experimental で入った --async-stack-traces
フラグ による Zero-cost async stack traces が node.js でサポートされることを祈っています。
なお、global.Promise
を Bluebird に差し替えて BLUEBIRD_LONG_STACK_TRACES=1
する手もあるのですが、Promise は各種ライブラリなどの内部で使われており、node.js 標準と異なる実装に差し替えるのは将来の地雷になりそうなのでまだ踏み切っていないです。
まとめ
このように JavaScript サーバーサイドの開発でも(他の言語・環境であってもそうであるように) yak shaving 的なケアが少なからず必要にはなりますが、しかしながらもブラウザ側とコードやライブラリなどを共有できるメリットがある上にエコシステムもかなり充実してきているので、試してみる価値はあるのではないでしょうか *12。
We are hiring
エムスリーは、このように新規技術へのチャレンジも積極的におこなっております。興味を持たれた方はぜひ以下のリンクからご応募ください:
*1:結構観客席を写しており、観客参加型のネタがいろいろあります
*2:待ち時間などに T シャツを投げて配るのは伝統行事だそう
*3:k8s も手だったのですが、JavaScript 側とインフラの両面からのチャレンジは片手間には厳しかったのと、k8s にするまでもない単一種類のコンテナを立てるだけの構成であったため取り敢えず ECS にしました
*4:どちらも Headless CMS に強く依存しているため
*5:node.js は非同期 I/O を活用することで限られたスレッド数で多くのリクエストを捌くというアーキテクチャなので、同期 I/O は避けるべきです。さもないと限りあるスレッドが同期 I/O に拘束されてしまいリクエスト処理が詰まってしまう。
*6:公称
*7:AWS ELB であれば draining 状態になる
*8:実体は expressjs/cookie-session
*9:大したことがないコード量でも 10 秒超え
*10:単純な JSON ではなくクラスになっており、ドキュメント同士の参照なども表現されている
*11:今回の運用ツールでは必要性が薄いのですが、将来的にエンドユーザー向けのサイトを作る際にはサーバーサイドレンダリングが必要になる可能性もあると捉えています。
*12:個人的には TypeScript が地味に良い。Delpi, C#, TypeScript を設計した Anders Hejlsberg は偉大な言語設計者だと思います。