SvelteKitでDBクライアントの初期化をどう行うか? connect ECONNREFUSED ::1:3306にならないために
はじめに
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エラーになっていたコードは以下のように実装していた。
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 };
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を見つけた。
そして、このissueに書かれいる内容を見るにプリレンダリングが関係しており、ビルド時にプリレンダリングされる=コードが一部実行されることになるので、それによりmysql2.createConnection
が実行されてしまっていると考えられる。
確かに/.svelte-kit/output/server/chunks/clients.js:25:14
を見てみると確かにトップレベルawaitでmysql2.createConnection
が呼ばれているコードがあった。
解決方法
event.locals
に格納する
関数でコネクションを返しserver.hooks.jsなどでこの方法は以下のようにclients.js
内でcreateConnectionを呼び出すのではなく、それを呼び出す関数を定義して、hooksで呼び出して初期化する方法。実装としては以下のようになる。
後からNGと分かった実装
最初はcreateConnectionを呼び出す実装を考えていたが、hooks.server.js
のhandle
は毎回実行される際に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:///...)
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 };
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回だけ初期化され、データベースへのコネクションを張れることができる。
コネクションプールを利用するような実装の場合であれば、以下のように実装できると思う。
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
という事。
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
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