🔖

SvelteKitでDBクライアントの初期化をどう行うか? connect ECONNREFUSED ::1:3306にならないために

2023/12/11に公開

はじめに

SveltKitでMySQLを利用したアプリの開発をしていた際、いざDeployのためにBuildするぞ!と思ってやってみたら、以下のようなエラーになった。

✓ 74 modules transformed.
.svelte-kit/output/client/_app/version.json                               0.03 kB │ gzip:   0.05 kB
...
.svelte-kit/output/client/_app/immutable/nodes/3.8c3b3de1.js            374.60 kB │ gzip: 126.35 kB
✓ built in 3.64s

node:internal/event_target:1016
  process.nextTick(() => { throw err; });
                           ^
Error: connect ECONNREFUSED ::1:3306
    at Object.createConnection (/home/study/workspace/cat-faq-bot/node_modules/mysql2/promise.js:253:31)
    at file:///home/study/workspace/cat-faq-bot/.svelte-kit/output/server/chunks/clients.js:25:14
    at ModuleJob.run (node:internal/modules/esm/module_job:194:25)
Emitted 'error' event on Worker instance at:
    at [kOnErrorMessage] (node:internal/worker:300:10)
    at [kOnMessage] (node:internal/worker:311:37)
    at MessagePort.<anonymous> (node:internal/worker:212:57)
    at [nodejs.internal.kHybridDispatch] (node:internal/event_target:741:20)
    at exports.emitMessage (node:internal/per_context/messageport:23:28) {
  code: 'ECONNREFUSED',
  errno: -111,
  sqlState: undefined
}

接続をしようとしたが接続できずエラーというよく見かけるエラーであるのは分かるが、このエラーがなぜBuild時に出るんだ…という事で躓いた。

今回はその原因と対応策として考えられる事をまとめたいと思う(以下で見ていく方法以外に、もっといい方法がある等ご存じの方がいればご指摘いただけると幸いです)。

Buildエラーになっていたコードについて

Buildエラーになっていたコードは以下のように実装していた。

src/lib/server/clients.js
import mysql2 from 'mysql2/promise';
import {
	MYSQL_HOST,
	MYSQL_USERNAME,
	MYSQL_PASSWORD,
	MYSQL_DATABASE
} from '$env/static/private';

const mysqlConnection = await mysql2.createConnection({
	host: MYSQL_HOST,
	user: MYSQL_USERNAME,
	password: MYSQL_PASSWORD,
	database: MYSQL_DATABASE,
	port: 3306,
	timezone: '+09:00'
});

export { mysqlConnection };
src/lib/server/database.js
import camelcaseKeys from 'camelcase-keys';
import { mysqlConnection } from '$lib/server/clients';

const selectData = async () => {
	const [rows] = await mysqlConnection.query(`SELECT * FROM hoge ;`);
	return camelcaseKeys(rows, { deep: true });
};

export { selectData };

やりたかったのは1度だけクライアントを初期化し、それをdatabase.jsで使いまわすという事。基本的に何回もコネクションを作成するような実装はしないので、これでいいと思っていたが、次の章で見ていくようにこれではBuildエラーになりNG。

※今回Buildエラーになった場面は、@sveltejs/adapter-nodeを利用していてBuildを行った時。

エラーの原因

同じようなことで躓いている人がいるのでは?と思い調べると、やはり全く同じエラーで困っているissueを見つけた。

https://github.com/sveltejs/kit/issues/933

そして、このissueに書かれいる内容を見るにプリレンダリングが関係しており、ビルド時にプリレンダリングされる=コードが一部実行されることになるので、それによりmysql2.createConnectionが実行されてしまっていると考えられる。

確かに/.svelte-kit/output/server/chunks/clients.js:25:14を見てみると確かにトップレベルawaitでmysql2.createConnectionが呼ばれているコードがあった。

解決方法

関数でコネクションを返しserver.hooks.jsなどでevent.localsに格納する

この方法は以下のようにclients.js内でcreateConnectionを呼び出すのではなく、それを呼び出す関数を定義して、hooksで呼び出して初期化する方法。実装としては以下のようになる。

後からNGと分かった実装

最初はcreateConnectionを呼び出す実装を考えていたが、hooks.server.jshandleは毎回実行される際にevent.localsは空になるので、if (!locals.mysql)は必ずtrueになり、毎回createConnectionが走り以下のようなエラーになるのでNG…。

Error: Too many connections
	at Object.createConnection (/app/node_modules/mysql2/promise.js:253:31)
	at createMysqlClient (file:///...)
src/lib/server/clients.js
import mysql2 from 'mysql2/promise';
import {
	MYSQL_HOST,
	MYSQL_USERNAME,
	MYSQL_PASSWORD,
	MYSQL_DATABASE
} from '$env/static/private';

const createMysqlClient = async () => {
	const mysql = await mysql2.createConnection({
		host: MYSQL_HOST,
		user: MYSQL_USERNAME,
		password: MYSQL_PASSWORD,
		database: MYSQL_DATABASE,
		port: 3306,
		timezone: '+09:00'
	});
	return mysql;
};

export { createMysqlClient };
src/hooks.server.js
import { createMysqlClient } from '$lib/server/clients';

export async function handle({ event, resolve }) {
	const { locals } = event;

	if (!locals.mysql) locals.mysql = await createMysqlClient();

	const result = await resolve(event);
	return result;
}

このように実装することで、プリレンダリング時にawait mysql2.createConnectionが実行されることがなくなり、一番最初にロードされたときに1回だけ初期化され、データベースへのコネクションを張れることができる。

コネクションプールを利用するような実装の場合であれば、以下のように実装できると思う。

src/lib/server/clients.js
const pool = mysql2.createPool({
	host: MYSQL_HOST,
	user: MYSQL_USERNAME,
	password: MYSQL_PASSWORD,
	database: MYSQL_DATABASE,
	port: 3306,
	timezone: '+09:00',
	connectionLimit: 10
});
const getMysqlClient = () => pool;

export { getMysqlClient };
import { getMysqlClient } from '$lib/server/clients';

export async function handle({ event, resolve }) {
	const { locals } = event;

	locals.mysql = getMysqlClient();

	const result = await resolve(event);
	return result;
}

ビルド時に接続可能なデータベースを立ち上げる

プリレンダリングでデータベースへの接続が行われる挙動のままでビルドしたいのであれば、issueでも言及されているが、CIなどでビルドを行う際にもデータベースを立ち上げることが必要になるだろう。

Build on a CI in your production environment that has access to production dependent services

その際にはGitHub Actionsのサービスコンテナを利用したりすればいいと思う。

まとめとして

今まではバクエンドの開発といえばNode.jsのExpressを利用することが大川原のでサーバーでDBに接続するときはExpressの起動時だったが、今回SveltKitを利用してみてビルドのプリレンダリング時にDBに接続するという事が起き、少し混乱したのと新鮮に感じた。

おまけ

関数でコネクションを返しserver.hooks.jsなどでevent.localsに格納するの方法を取った背景として、PoCとして検証しようとしたアプリがMySQL・Redisに依存しており、それをEC2上でdocker-composeで立ち上げようという雑なことをしたかったため。つまりは以下のようなdocker-compose.yamlという事。

docker-compose.yaml
version: '3.9'
services:
  app:
    container_name: app
    image: app:latest
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - 80:3000
  redis:
    image: redis:7.2.1-alpine3.18
    container_name: redis
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout: 5s
      retries: 3
    environment:
      TZ: 'Asia/Tokyo'
    volumes:
      - ./data/redis:/data
    ports:
      - 6379:6379
  mysql:
    image: mysql:8.0.32
    container_name: mysql
    healthcheck:
      test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
      interval: 10s
      timeout: 5s
      retries: 3
    environment:
      MYSQL_ROOT_PASSWORD: ''
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
      TZ: 'Asia/Tokyo'
    ports:
      - 3306:3306
    volumes:
      - ./data/mysql:/var/lib/mysql
      - ./mysql/sql:/docker-entrypoint-initdb.d
      - ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf
Dockerfile
FROM node:18-alpine3.17 AS builder

RUN mkdir /app
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .

RUN yarn build
RUN mkdir for-next-stage && \
    mv build entrypoint.sh package.json yarn.lock for-next-stage/

FROM node:18-alpine3.17

RUN mkdir /app
COPY --from=builder /app/for-next-stage /app
WORKDIR /app

RUN yarn install --frozen-lockfile --production

ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]
EXPOSE 3000

上記の設定でappはSveltKitで実装したアプリ(マルチステージビルドになっているが、ローカルの開発環境で検証するときに都度ビルド前のyarn installをしてほしくなかったのでこういう構成になっている)。

こうしておけば、EC2でアプリとDBの両方をサクッと立てて検証ができる。ただ、この方法を取る場合、エラーの原因で見たように、mysql2.createConnectionをするとMySQLがビルド時に立っていないとビルドが通らない…という事になるので、PoCで面倒なので…という事で関数でコネクションを返しserver.hooks.jsなどでevent.localsに格納するの方法を取った。

Discussion