この記事はtraP新歓ブログリレー2024、34日目の記事です。
はじめに
こんにちは!23Bの@cp20です!
今日はちょっと環境をプチ破壊しかけたしくじり事例について紹介していこうと思います。
何が起こったか
定期的にデータベース (MongoDB) にデータを保存して、フロント (Nuxt.js) からのリクエストが来た時にそこから値を読み出すようなアプリを作っていました。Nuxt.jsはフルスタックフレームワークなので、バックエンドもNuxt.jsで書いていました。
Nuxt.jsではバックエンドでの処理を1つの関数 (リクエストハンドラー) として書くことができて、そこにフロントからアクセスすることができます。今回はデータベースから値を読み取って返すだけのリクエストハンドラーを登録していました。
バックエンドがデータベースから値を取得する時、クエリというものを投げるのですが、クエリを投げるためにまずコネクションというのを確立する必要があります。お互いにやり取りするためにまず連絡先を交換するみたいなイメージです。
しかしボクのしてしまったまずい実装では、リクエストハンドラー内で毎回新たなコネクションを張るようになっていました。
するとどうなるか。リクエストごとにコネクションを張りまくる最悪のバックエンドが誕生します。しかもそれを閉じていないのでずっとコネクションが溜まっていきます。
そしてサーバーは爆発しました。
その後じんわりとサーバーの負荷が高くなってるよアラートが出ていたのですが、いつものことかと思って放置していました。ボク一応SysAd班インフラチームのメンバーなんですが、いったい何をしているんでしょうね...?
解決策
Nuxt.jsのServer Pluginsを使って起動時に1つコネクションを張った上で全リクエストで使いまわすように書き換えました。
具体的にはserver/plugins/mongodb.ts
に次ののような内容を書いて、リクエストハンドラー内からはevent.context.db
、event.context.collections
としてアクセスするように変更しました。
export default defineNitroPlugin(async (nitro) => {
// 起動時に1回だけ実行される
const { client, db, collections } = await connect();
// 終了時
nitro.hooks.hook('close', () => {
client.close();
});
// リクエスト時
nitro.hooks.hook('request', (event) => {
event.context.db = db;
event.context.collections = collections;
});
});
ちなみにリクエストごとに新たにコネクションを張るオーバーヘッドがなくなった分、体感で100~200msぐらいレスポンスが速くなりました。嬉しいね。
教訓
DBコネクションを張る場所には気を付けよう!
昨今のバックエンドは抽象化が進んでエンドポイント単位 (リクエストハンドラー単位) でしっかりと分割されるようになりましたが、そうすると逆にこういう落とし穴にはまりがちなので気を付けましょう。
おわりに
明日は @ramdos っちの担当です!お楽しみに!
ちなみにボクの次のブログは5日後です。また会いましょう~
おまけ
タイトルはsetIntervalの第2引数に気を付けろというだけの話をオマージュしました (内容も割とオマージュしています)
おまけ2: コネクションプールについて補足
traP内でレビューを募ったらコネクションプールの話をされたので個人的に調べたこともあわせて補足しておきます。
ボクがやったことをもう少し具体的に書くと、もともとMongoDBのconnect
メソッドをリクエストごとに呼ぶ設計だったのを一回だけconnect
を呼んで使いまわす設計に変えたという話です。MongoDBはおそらくconnect
メソッドが呼ばれたタイミングでコネクションプールを作り、minPoolSize
の数だけ先にコネクションを張っておいて、クエリを投げたタイミングで (もしコネクションが足りなかったら?) 追加でmaxPoolSize
まではコネクションを張ります。デフォルトでは最小が0、最大が100になるみたいです。
ボクの最初の設計で問題だったのは、リクエストごとにコネクションプールを作ってコネクションを張っていたことというわけですね。コネクションをいくら張っても限界がmaxPoolSize
で切られることなく、それぞれのコネクションプールごとにコネクションが溜まっていく (そして切断されずにいる) のでだんだんとデータベースに負荷がかかるというわけでした。
なので記事のタイトルを正確に言えば「DBコネクションプールを作る場所には気を付けよう」という感じかもしれません、が分かりにくいのでこのままにしておきます。
参考記事: https://qiita.com/tomoyanp/items/1041fc3ab60ef9c8b10c
おまけ3: データベースサーバー側での対策
今回コネクションが大量に張られてしまったが故にサーバーが爆発してしまったわけですが、ユーザーの怪しい実装は制御できないのでいつ何時サーバーを爆発させてしまうか分かりません。特にtraPのサーバーはかなり貧弱[1]なので、怪しいことをするとすぐに落ちてしまいます。
そこでデータベースのサーバー側からコネクション数を制限することでアプリは正常は動作しなくなる (かもしれない) 代わりにサーバーの無事を担保することができます。MongoDBの net.maxIncomingConnections オプションを使うことでコネクション数を制限できるみたいです。デフォルトだと100万近くあるので、これを適当に制限してあげると良いのではないでしょうか。
数GBのVPSn台に適当にサービスやらを分散させています ↩︎