見出し画像

その実装で大丈夫?!DBクライアントのコネクションはいつ作られるかを確認してみた ioredisを例に

はじめに

こんにちは、SHIFTの開発部門に所属している Katayama です。

先日、特に何も考えないで以下のようなコードを書いていました(全体は「おまけ」を参照ください)。単純な ES6 クラスで、ログインを何回か失敗した際にそのログインをロックするために Redis でログイン失敗回数を保存する、という機能のためのクラスです。

この実装、DB へのコネクションの観点からまずいかもなと思っていたのですが、具体的にどこがよろしくない実装なのか?を Redis へのコネクション状況を見て実際に確かめてみました。

// src/lib/login-lock.js
import config from "config";
import Redis from "ioredis";

export default class LoginLock {
  constructor(options = {}) {
    this.redis = new Redis(options.redis);

    this.key = options.key || config.get("default.key");
    this.failLimitCount = options.failLimitCount || 5;
    this.expire = options.expire || config.get("default.expires");
  }

  // 省略
}

ioredis は Redis の Node.js 版のクライアントです。

Redis へのコネクションはいつ作られるか?

今回は簡易的に確認したかったので、Jest で new Redis をして、以下のコマンドからクライアントの接続するを確認してみようと思う。

redis-cli info clients

実装はシンプルで、以下のような実装で確認をしてみた(ESLint に怒られる実装をしているが、まあ今回は無視する)。

import Redis from "ioredis";

const config = {
  port: 6379,
  host: "127.0.0.1",
};
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

describe("connect for DB", () => {
  test("new Redis only", async () => {
    await sleep(2000);

    new Redis(config);

    await sleep(2000);

    new Redis(config);

    await sleep(2000);

    new Redis(config);
  });
});

この実装をした上で、docker-compose(docker でもいい)でRedisを立てて、以下のコマンドでコンテナ内に入った上で Redis の clients 情報を watch しておく。

[study@localhost node-express]$ docker container exec -it redis /bin/sh

/data # watch -n 1 redis-cli info clients

テストを実行して、Redis へのコネクションを見てみると、以下の動画の通り、new Redis が実行されるごとにコネクション数(connected_clients)が増えている事が確認できる(最初から connected_clients:1 であるのは、clients の情報を参照している redis-cli が Redis に接続しているため)。つまり、ioredis では new Redis をしたタイミングでコネクションを張る仕様になっているという事。

※redis-cli だけでなく、は netstat コマンドでも以下のようにコネクションの状況を確認できる(以下は、Jest のテストを強制終了する前にコンテナ内で実行したコマンド)。

/data # netstat | grep ESTABLISHED
tcp        0      0 538c86056e4a:redis      172.18.0.1:37586        ESTABLISHED
tcp        0      0 538c86056e4a:redis      172.18.0.1:37594        ESTABLISHED
tcp        0      0 538c86056e4a:redis      172.18.0.1:37578        ESTABLISHED

※上記では Jest で確認を行っていたが、これは Jest であれば babel の設定ファイルを置いておくだけで、テスト実行時にトランスパイルが走るので、ES Modules を利用した実装が簡単にできるため。Jest で ES Modules(ES6 以降の構文)を利用する方法についてはNode.js(ES6 で実装)で Jest によるテストを実行するための Babel・ESLint の設定とは?などを参照)ちなみに、Jest で以下のようなメッセージが出ていたが、これは何らかのプロセス(今回は Redis へのコネクション)が残っているため、Jest が終了できなかったために出ているメッセージ。

Jest did not exit one second after the test run has completed.

This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with --detectOpenHandles to troubleshoot this issue.

という事は・・・

「はじめに」で提示していたようなクラスの実装は、都度コネクションを張るためコネクションがどんどん増えていく実装になっているため、アクセスが増えてきた時に、以下のような問題が出ることがある。

  • Redis 側のクライアント接続数上限に達してしまい、アプリケーションが動かなくなる。

  • アプリケーション側がコネクションを作成する際に使用するポートが枯渇したり、Linuxのtcpコネクション上限に達して動かなくなる。

そして、この問題は Deploy 後本番環境ですぐに出るわけではなく、アクセス数が増えていきある日突然起きるトラブルなので、トラブル対応にものすごいコストがかかってしまうことが想像できる。その意味では、コネクションを都度作成するような実装ではなく、ioredis のインスタンスをクラスの外部から渡す実装にし、コネクションのトラブルが未然に発生しないようにすべきと言える。具体的には以下のようなイメージだろう(ioredis のインスタンスがコンストラクタに渡ってきているか?などの本考慮すべき事はここでは省略している)。

	constructor(redis, options = {}) {
		this.redis = redis;

		this.key = options.key || config.get('default.key');
		this.failLimitCount = options.failLimitCount || 5;
		this.expire = options.expire || config.get('default.expires');
	}

※計測していないので定量的には不明だが、やはりコネクションを都度張る実装だと、そのコネクションを張るコストの分だけ速度が遅くなるので、その意味でも都度new Redisをする(コネクションを張る)実装は不利になると思われる。

まとめとして

今回は ioredis を例に、DB クライアントのコネクションがいつ作られるか?その仕様から考えた実装方法についてみてきた。コネクションプールを作成するような実装をする際には、その時にコネクションがプールされるのでコネクションが作られている事が分かりやすいが、そうでない場合にはクライアントの仕様に注意しないといけないと思った。

※ちなみに、今回はわざわざ検証して確かめたが、リファレンスを読めばquitdisconnectなどのメソッドがあるので、そこから new Redis をしたタイミングでコネクションが作られるだろう、という事は想像できると思う。

おまけ

「はじめに」で提示していた ES6 クラスのコードの全体は以下のような実装。

// src/lib/login-lock.js
import config from "config";
import Redis from "ioredis";

export default class LoginLock {
  constructor(options = {}) {
    this.redis = new Redis(options.redis);

    this.key = options.key || config.get("default.key");
    this.failLimitCount = options.failLimitCount || 5;
    this.expire = options.expire || config.get("default.expires");
  }

  async incrFailCount() {
    await this.redis
      .pipeline()
      .incr(this.key)
      .expire(this.key, this.expire)
      .exec();
    return this.#getFailCount();
  }

  async isLock() {
    const failedCount = await this.#getFailCount();
    return failedCount >= this.failLimitCount;
  }

  async #getFailCount () {
    const count = await this.redis.get(this.key);
    return count || 0;
  }

  async reset() {
    await this.redis.del(this.key);
  }

  async quit() {
    await this.redis.quit();
  }
}

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/