ExpressからFastifyへ移行した理由:本番環境での6ヶ月間にわたる事後検証

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Expressという「デフォルト」からの脱却

Express.jsは10年以上にわたり、Node.jsエコシステムのバックボーンであり続けてきました。週末の個人開発から大規模なエンタープライズサービスまで、あらゆるプロジェクトで私の第一選択肢でした。しかし、6ヶ月前、私たちのチームは壁に突き当たりました。秒間15,000リクエストを処理するマイクロサービスを管理していましたが、綻びが見え始めていたのです。積極的なキャッシュや垂直スケーリングを行っていたにもかかわらず、p99レイテンシは頻繁に500msを超え、CPU使用率は常に85%という、不安を感じるほど高い数値で推移していました。

パフォーマンスの問題だけではありません。私たちは「型ドリフト(Type Drift)」の問題にも直面していました。TypeScriptを使用していても、Expressのリクエストボディは手動でバリデーションを行うまで、実質的にany型でした。実際の機能開発よりも、同じようなボイラープレートコードを書くことに多くの時間を費やしていました。その苦闘の末、私たちはスタック全体をFastifyへ移行することを決断しました。本番環境で半年間運用した結果、得られたのは単なる処理速度の向上だけではありませんでした。APIの構築方法そのものが根本から変わったのです。

現実世界のボトルネック:ミドルウェアと手動バリデーション

主な問題は単なる実行速度ではありませんでした。本当の致命傷は、ミドルウェアパターンのオーバーヘッドと、ネイティブなスキーマ強制機能の欠如でした。典型的なExpressアプリでは、バリデーションは後回しにされがちです。通常、JoiやYupのようなライブラリを導入し、ミドルウェアとして実行し、TypeScriptのインターフェースがそれらのバリデーターと同期していることを祈るしかありません。

コードベースが大きくなるにつれ、3つの具体的な問題が浮き彫りになりました:

  • シリアライズのオーバーヘッド: 標準のJSON.stringify()は驚くほど重い処理です。大きなペイロード(100KB以上)を扱う場合、高コンカレンシーのピーク時にはイベントループを数ミリ秒間ブロックしてしまいます。
  • メモリの肥大化: Expressには組み込みのライフサイクル管理システムがありません。そのため、データベース接続などのシングルトンパターンが乱立し、デプロイ時に適切にクリーンアップするのが困難でした。
  • バリデーションの漏れ: 開発者が新しいルートにバリデーションミドルウェアを適用し忘れることが時折ありました。その結果、TypeScriptでは検知できない実行時エラーが発生していました。

なぜExpressは現代の要求に苦戦するのか

ExpressはNode.jsの古い時代の産物です。そのルーティングロジックは、ミドルウェアを線形にスキャンすることに依存しています。ルートが5つなら問題ありませんが、50個になると目に見えて速度が低下します。さらに、Expressは極端なまでに「非意見的(unopinionated)」です。データのバリデーション方法に無関心であるため、チーム間でパターンがバラバラになりがちです。

Node.jsは進化しましたが、Expressのコアはほとんど変わっていません。現代の高パフォーマンスAPIは、事前コンパイル(AOT)最適化の恩恵を受けます。Fastifyは、JSONスキーマを高度に最適化されたバリデーション関数に事前コンパイルすることでこれを実現しています。これは、Expressがそもそも想定していない設計です。

候補の比較検討

  1. NestJS: 堅牢なフレームワークですが、この特定のマイクロサービスには重すぎると感じました。内部でFastifyを使用することも可能ですが、私たちのニーズに対して抽象化レイヤーの複雑さが見合いませんでした。
  2. Koa: ミドルウェアの制御は優れていますが、バリデーションやOpenAPIドキュメントの作成には依然として手動での組み立てが必要です。
  3. Fastify: 大幅なスループットの向上を約束しており、Ajvによるスキーマバリデーションを備えた組み込みのプラグインシステムを特徴としています。

Fastifyが選ばれた理由は、スキーマをコア機能として扱っているからです。fast-json-stringifyを使用してレスポンススキーマを事前コンパイルするため、標準ライブラリよりも2倍速いことがよくあります。高トラフィックなエンドポイントを持つ私たちにとって、これは大きな勝利でした。

スキーマファーストへの転換

ここからがFastifyの本領発揮です。コードを書いてからドキュメント化するのではなく、Fastifyではデータの形状を最初に定義することが推奨されます。@sinclair/typeboxを使用することで、実行時のバリデーターとTypeScriptの型定義の両方の役割を果たす単一のスキーマを定義できます。

import { Type } from '@sinclair/typebox';
import Fastify from 'fastify';

const server = Fastify();

const UserSchema = Type.Object({
  id: Type.String(),
  name: Type.String(),
  email: Type.String({ format: 'email' }),
});

// 型は自動的に推論されるため、手動でインターフェースを定義する必要はありません
type User = typeof UserSchema.static;

server.post<{ Body: User }>('/users', {
  schema: {
    body: UserSchema,
    response: {
      201: UserSchema,
    },
  },
}, async (request, reply) => {
  const { name, email } = request.body;
  return { id: '1', name, email };
});

このアプローチにより「型ドリフト」の問題は解消されました。スキーマを変更すれば、TypeScriptの型も自動的に更新されます。さらに素晴らしいことに、Swaggerドキュメントも追加の労力なしで完全に同期した状態が保たれます。

プラグインシステムによるカプセル化

データベースプールや共有設定などのグローバルな状態をExpressで管理すると、通常、インポートが入り乱れて混乱を招きます。Fastifyは、register API and fastify-plugin (fp) ラッパーでこの問題を解決します。これにより、アプリの他の部分に誤って漏れ出さない、カプセル化されたモジュールを作成できます。

import fp from 'fastify-plugin';

export default fp(async (fastify, opts) => {
  const db = await createDbConnection(opts.uri);
  
  // Decorateにより、fastifyインスタンスから 'db' を利用可能にします
  fastify.decorate('db', db);

  fastify.addHook('onClose', async (instance) => {
    await instance.db.close();
  });
});

テストが格段に容易になりました。Fastifyインスタンスを立ち上げ、テストに必要な特定のプラグインだけを登録して実行できるため、アプリケーションの他の部分からのサイドエフェクトが一切ありません。

本番環境で譲れない3つの最適化

Fastifyへの移行は、単にライブラリを変えるだけではありません。より優れた本番運用の習慣を取り入れることでもあります。ここでは、私たちがすべてのサービスで使用している3つの最適化を紹介します:

1. Pinoによる高速ロギング

FastifyにはPinoが標準搭載されており、Winstonよりも約10倍高速です. 他のロガーとは異なり、PinoはデフォルトでJSON形式でログを出力し、CPU負荷も無視できるほどわずかです。高トラフィックな環境では、標準のコンソールログがイベントループをブロックすることがありますが、Pinoはこれを完全に回避します。

2. 信頼性の高いグレースフルシャットダウン

プロセスを突然終了させてはいけません。KubernetesのローリングアップデートなどでSIGTERMシグナルを受け取った際は、データベース接続を閉じ、アクティブなリクエストを完了させる必要があります。Fastifyのフックシステムを使えば、これは非常に簡単です。

const start = async () => {
  try {
    await server.listen({ port: 3000, host: '0.0.0.0' });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};

['SIGINT', 'SIGTERM'].forEach((signal) => {
  process.on(signal, async () => {
    server.log.info(`${signal}を受信しました。シャットダウンしています...`);
    await server.close();
    process.exit(0);
  });
});

3. 標準化されたエラーハンドリング

Expressのエラーハンドリングは、try-catchブロックが混在してカオスになりがちです。Fastifyは、中央集権的なsetErrorHandlerを提供します。これにより、すべてのエラーが標準的なフォーマットに従うようになり、機密性の高いスタックトレースがユーザーに漏洩するのを防ぐことができます。

最終的な結論

数字がすべてを物語っています。移行後、平均レイテンシは30%低下し、メモリ使用量は25%削減されました。しかし、本当の勝利は開発者体験にありました。TypeScript、TypeBox、そして厳格なスキーマバリデーションの組み合わせにより、バグを午前2時の本番ログではなく、開発中に発見できるようになったのです。

シンプルなCRUDアプリを作っているなら、Expressは今でも良い選択肢です。しかし、スケーリングが必要な場合、複雑なデータを扱う場合、あるいは高い信頼性を維持する必要がある場合は、Fastifyが優れたツールとなります。Fastifyはデフォルトでより良いコードを書くことを強制してくれます. 将来の自分、そして運用チームも、あなたに感謝することでしょう。

Share: