Skyfall Tech Blog
🛠️

APIをC#からNode.js + Hono + Zod + Kysely + TypeSpecにリプレイスして3倍以上高速化しました

に公開

はじめに

株式会社SkyfallではSKYFLAGという広告マネタイズのサービスを提供しています。

https://skyflag.info/

SKYFLAGにはオファーウォールという機能があり、ユーザーの興味関心にあった広告の提供をおこなっています。
今回は、オファーウォールで利用しているAPIのリプレイスを行ったので、その内容について共有したいと思います。

自己紹介

普段、SKYFLAGの開発をメインで行なっている xaicron です。

いままでは、某ソーシャルゲームプラットフォームのAPIを開発したり、変わったニュースアプリを作ったり、広告配信の最適化のサービスをつくったり、お魚を売ったりしていました。

結論

  • 一番遅いAPIで平均300ms掛かっていたものが80msになりました
    • 数百件ほどの広告データを返す、そこそこ大きめのものです
  • サーバースペックとDBスペックを下げることに成功しました
    • これによって年間で数百万円ほどのコスト削減になる想定です
  • 大幅にアプリケーションの設計を見直したため、開発のしやすさが飛躍的に向上しました

免責

リプレイスに至った経緯

現状の開発組織の課題と、アプリケーションの課題の両面から考え、これはリプレイスした方がいいなと決意しました。

組織課題

  • 現在 C# の有識者がチーム内にいない
  • サーバーサイド C# の人材の採用が難しい

アプリケーションの課題

  • 昔あったシステムに積み上げる形で、短期間で突貫で作られたため構造が整理されていない
    • ファットモデル・ファットコントローラー状態
    • ほぼ同じなのに、ちょっと違うコードが大量にあり、機能追加・修正が難しい
  • 1つのエンドポイントが複数のリソースを返している
    • ページで必要なデータを丸ごと全て返す形式
    • それぞれのエンドポイントで似たような処理が書かれている
    • レスポンスサイズが大きい
  • DBの扱い方が富豪的
    • 基本的に SELECT * で全リソースを取得している
    • 依存関係がないテーブルへのアクセスがシーケンシャルになっている

上記は一例ですが、いろいろなアンチパターンが混在しているような状態でした。

リプレイスの方針

いろいろと課題があったため、ちゃんとしたAPIを作ることに決めました。
前提として、今まで動いてた API はしっかりと収益を上げてきたものなので、敬意を表することを忘れずに、感謝の意を込めてちゃんと引導を渡せるようにします。

目標は大きく以下になります。

  • チームメンバーがキャッチアップしやすく、開発しやすい
  • APIを分割し、それぞれを小さく保ち疎結合にする
    • リソースやデータモデリングを適切に設計し、機能追加・改修を行いやすくする
  • パフォーマンスを向上させる

リプレイス前の技術スタック

元々は C# の AspNetCore で実装されていました。
インフラは一般的な ECS Fargate + Aurora の構成です。

アプリケーション

  • C#
  • AspNetCore.Mvc / AspNetCore.OpenApi
  • Dapper + MysqlConnector

インフラ

  • CloudFront
  • ALB
  • ECS Fargate
  • Aurora MySQL

リプレイス後の技術スタック

リプレイス後は以下のような構成に変更しました。
インフラについては、ほぼそのままになっています。

アプリケーション

  • Node.js
  • TypeScript
  • Hono
  • Kysely + mysql2
  • Zod
  • TypeSpec

それぞれ、選定理由は以下となります。

Node.js

サーバーサイド JavaScript エンジンとしては最も枯れています。
最初は Bun を利用して開発していたのですが、メトリクスに利用している Datadog でうまくトレースできない問題があり、途中で Node.js に切り替えました。
幸い、Hono を利用していたので、書き換える箇所もほとんどなく、Node.js に切り替えることができました。(とはいえ、Denoだったら結構大変だったと思われます。)
また、個人的に Node.js での API 運用経験もあったため。

TypeScript

サービスのフロントエンドや管理画面は React + TypeScript で実装しており、チームとの親和性が高いため採用しました。
弊社では基本的に、エンジニアはフロントエンドもバックエンドも両方開発を行っています。

Hono

現在、ほぼデファクトスタンダードと言ってもいいと思います。
書き味も昔懐かしの素朴な感じであり、ホットリロードなど含めてDXがとても良いです。
Hono 自体がボトルネックになることがないぐらいにはチューニングもされているので、安心して利用できます。

Kysely + mysql2

なるべく SQL に近い書き味でクエリーを書きたいため、Kysely を採用しました。
スキーマの型定義については、kysely-codegen を利用して生成しています。
マイグレーション等は現時点では別のプロダクトで行なっているため、このようにしています。
Kysely を利用することで、コンパイル時点で型のチェックが行われるため、実行時エラーを最小限に抑えることができます。

mysql2を利用することで、コネクションプーリングも自動で行ってくれます。

Zod

こちらもデファクトスタンダードです。
Valibotなどもありますが、今回は最もエコシステムが整っている Zod にしました。

TypeSpec

今回のチャレンジ枠になります。
まだまだ模索中な部分もありますが、ひとまず目的は達成できました。
詳しくは後述します。

インフラ

  • CloudFront
  • ALB
  • ECS Fargate
    • local Redis
  • Aurora MySQL

ECS Fargate のところに見慣れない local Redis がありますが、キャッシュ用途で利用しています。

TypeSpecについて

もともとの C# の API で OpenAPI 3.1 を利用しており、大きくインターフェイスを変更する強いモチベーションがなかったため、そのまま OpenAPI 3.1 ベースの API とすることにしました。

Hono には @hono/zod-openapi や hono-openapi など、プログラムで記述したものから OpenAPI を生成する仕組みがあります。
こちらをそのまま利用するのでも良かったのですが (実際に開発当初はそうしていました)、APIの仕様をドキュメントとして残したいモチベーションがあり、 TypeSpec を利用した方が見通しがよいと考え、採用しました。

実際に新しいAPIを開発する際は、TypeSpecを書いて、レビューを行うようにしています。

TypeSpecからZodの型を生成する

せっかく TypeSpec で型情報を書いているため、実際のコードでもそのまま利用できると嬉しいです。
今回は、もっとも広く利用されている Zod でリクエストのバリデーションや型チェックを行うことにしました。

TypeSpec には emitter という仕組みがあり、Zod の型定義も同時に生成してくれるものがいくつかあります。(bterlson/typespec-zodや@typespec-tools/emitter-zodなど)
これを利用すれば、TypeSpec から直接 Zod の型を出力することが可能です。
しかし、emitter framework はまだ実験的な機能であることと、上記のライブラリーも微妙に痒いところに手がとどかなかったりしたため、emitter を利用する方向はひとまずやめることにしました。

Orvalでopenapi.yamlからZodの型を生成する

考え方を変えてみます。
TypeSpecからはopenapi.yamlを生成しています。このファイルに必要な型の情報は網羅されています。つまり、openapi.yamlからZodの型を生成できれば良さそうです。
そこで Orval を利用することにしました。

https://orval.dev/

OrvalはOpenAPIのスペックからAPIのボイラープレートやモック、型やバリデーターなどを生成することができるツールです。
まさに今回の用途にうってつけになります。

ちなみに、Honoのボイラープレートも生成することが可能となっていますが、それに気付いたのは実装を終えた後だったので今回は利用していません。

モデルそれぞれの型とリクエスト・レスポンスの型を生成したかったため、以下のような設定を行いました。

orval.config.ts
import { defineConfig } from "orval";

export default defineConfig({
  skyflag: {
    output: {
      client: "zod",
      mode: "single",
      schemas: "./src/schema/models.gen",
      target: "./src/schema/zod.gen.ts",
      override: {
        useDates: true,
        zod: {
          coerce: {
            query: ["number"],
          },
        },
      },
    },
    input: {
      target: "./openapi.yaml",
    },
  },
});

カレントディレクトリにある openapi.yaml から models.gen と zod.gen.ts を生成しています。(genとつけているのは自動生成であることを明示するため)
今回は、Date型をそのまま利用したかったのと、query parameterで数値が入るところを number としてアプリケーションで扱いたかったため override を設定しました。

現時点ではまだできていませんが、将来的にはここで生成した型を、フロントエンドでも利用する予定です。

パフォーマンスチューニングについて

さて、ここからが本題です。
実際にボトルネックになっている部分を観測し、対応していきます。

負荷テストを行う

実際にパフォーマンスチューニングを行う前に、負荷をかけてボトルネックを洗い出します。
これには本番環境と同等のデータが必要になるため、一時的に本番のレプリカ環境を作成します。

負荷テストでは、k6 を利用して実際に API を叩き、Datadog や RDS でメトリクスを確認していきます。
ここで、Performance Insightなどに上がってきた SQL や、CPUやメモリーを食っているリクエストなどに当たりをつけていきます。

「推測するな、計測せよ。」の精神に則り、着実に数値を改善していくことが大事になってきます。

インデックスが効いていないクエリーを修正

リクエストによってはクエリーのインデックスが効かず、顕著に遅くなっているケースがありました。
また、昔はおそらくデータ量が少なかったために問題がなかったものも、少しずつ遅くなっているといったものもありました。

地道にインデックスを修正し、負荷テストを行い修正されたかどうかを確認していきます。
また、クエリー自体も変更し、より効率的にしていきます。

SQLを並列で実行する

今までのアプリケーションでは、SQL は全て直列で実行されていました。
JavaScript には Promise.all() があるため、これを使えば簡単に並列でリクエストを送ることができます。

async getSomething() {
    const [user, cart] = await Promise.all([
        userRepository.getUserData(),
        cartRepository.getCartData(),
        ...
    ]);
    return { user, cart };
}

こうすることで、待ち時間が Promise.all の処理で最も遅い処理に集約されます。
例えば、getUserData()が20ms、getCartData()が30msだとします。
並列処理をしなければ単純計算で50ms処理時間がかかりますが、上記のように並列処理を行うと、getSomething()は30msになるイメージです。

ローカルRedisをキャッシュとして活用する

ほとんどのデータは頻繁に更新されないため、キャッシュを行いたいと思います。
昔はキャッシュといえば memcached でしたが、近年では Redis を使う方が一般的です。

というわけで、MySQL から取得したデータを透過的に Redis にキャッシュする仕組みを作成しました。
基本的にはスパイク耐性を高める目的で、キャッシュヒット率が高いものに限定して、ごく短期間(数秒程度)キャッシュするようにしています。
こうすることでキャッシュインバリデーションの手間を無くしています。

初めは、Valkeyを利用しようとしていましたが、ネットワークの往復遅延(RTT:Round-Trip Time)が無視できなかったため、アプリケーションのローカルに同居させる形にしました。
こうすることでRTTは限りなく0になるため、キャッシュとしての効果を最大限発揮できるようになりました。

CPUヘビーな処理に対応する

一部のリクエストでCPU負荷が高まっているケースがありました。
パターンとしては大きく以下に分けられます。

  1. プログラムでのループが2重、3重になっている
  2. 不要な大量のデータを処理している

1にループについてはデータ構造を整理し、地道にループを減らしていきます。

2の不要な大量のデータについては、例えば「注意文言」のような大きめのテキストデータがあげられます。
これについては、一覧取得のAPIでは不要なため、フィールドの取得をやめるなどの対応を入れていきました。
JSON.stringify に大量のデータを渡すとCPUを食ってしまうため、なるべく最小限のデータを返すようにします。

リプレイスしてどうなったか

リプレイス後は以下のようになりました。

  • レスポンスタイムが3分の1以下に減少
  • データベースへのアクセスが大幅に減少
  • サーバースペックを大幅に縮小
  • 不要なデータを返さなくなったため、トラフィックが大幅に減少

結果、ページの表示速度が大幅に改善しました。

まとめ

C# から Node.js (TypeScript) にリプレイスを行い、短期間で従来よりも高速かつ改修しやすい状態にすることができ、当初の目的をほぼ達成することができました。
もちろん C# で同じような観点で全てを書き換えたらもっと高速になる可能性はありましたが、おそらく今よりも時間がかかっていたのと、今後のメンテナンスもされにくい状態だったと思います。

とはいえ、まだまだ改善の余地があるので、引き続きチューニングしていきたいと思います。

Hiring

最後に、Skyfallでは現在、グローバルでハイトラフィックに利用されるAPI開発ができる人材を募集しています!
ちょっとでも興味ある方はカジュアルにお話ししましょう〜!

Skyfall Tech Blog
Skyfall Tech Blog

Discussion