🌏

SvelteKit × Cloudflare Workers(Pages Function)でサーバーレスRedisを使う

2023/11/10に公開

はじめに

SveltKitで簡単なBotアプリ[1]を開発していた時に、KVSを利用する必要が出てきた。

はじめはCloudflare KVの利用を考えていたが、普段KVSというとRedisなどを業務では利用しているのでRedisを使いたいな~と思った。

今回はサーバーレスのRedisサービスUpstashを見つけたので、これを利用して

  1. ローカルでの開発時には本番サービスに接続せず開発を行う方法
  2. CloudflareにDeployした後正常に利用できるか?の検証

の2点について取り上げたいと思う。

※SveltKit × Cloudflare Workers(Pages Function)でCloudflare KVを利用する方法はQiitaの記事が参考になると思う。

Cloudflare WorkersからUpstash Redisを利用する方法

UpstashのRedisはサーバーレスなので、HPPTリクエストでRedisに対しての操作を行う。そのため、ユースケースにも以下のように書かれている通り、エッジコンピューティング環境での利用との相性がいい。

Edge functions: Edge computing (Cloudflare workers, Fastly Compute) is becoming a popular way of building globally fast applications. But there are limited data solutions accessible from edge functions. Upstash Global Database is accessible from Edge functions with the REST API. Low latency from all edge locations makes it a perfect solution for Edge functions

今回はCloudflare Workerからのアクセスになるが、その際にはCloudflare Workersに書かれている@upstash/redisというライブラリを利用することができる。実装はシンプルで、以下のようになる(envにUpstashのRedisのエンドポイントの情報がある前提)。

import { Redis } from "@upstash/redis/cloudflare";

export default {
  async fetch(request, env, ctx) {
    const redis = Redis.fromEnv(env);
    const count = await redis.incr("counter");
    return new Response(JSON.stringify({ count }));
  },
};

SveltKitの場合

SveltKitの場合も全く同じで、@upstash/redis/cloudflareから{ Redis }をインポートしてインスタンス化すればいい。

src/routes/api/upstash/+server.js
import { json } from '@sveltejs/kit';
import { Redis } from '@upstash/redis/cloudflare';
import { REDIS_ENDPOINT, REDIS_TOKEN } from '$env/static/private';

const redis = new Redis({ url: REDIS_ENDPOINT, token: REDIS_TOKEN });

export async function GET() {
	const count = await redis.incr("counter");
	return json({ count }, { status: 200 });
}

Redis.fromEnvコードを見ればわかるが、コンストラクタでインスタンス化するのを少し便利にするためのstaticなメソッドであり、別にこれを利用せずともコンストラクタのコードで実装できる。

ローカルの開発環境でUpstashのRedisに接続しないで開発をする

Serverless Redis HTTP (SRH)というリポジトリがあり、その開発者がサーバレスRedisへのHTTPリクエストをRedisにプロキシするようなDockerイメージを作ってくれているので、それを利用する。必要な準備は以下のようにdocker-compose.yamlを記載するだけ。

docker-compose.yaml
version: '3.9'
services:
  redis:
    image: redis:7.2.1-alpine3.18
    container_name: redis
    environment:
      TZ: 'Asia/Tokyo'
    volumes:
      - ./data/redis:/data
    ports:
      - 6379:6379
  serverless-redis-http:
    image: hiett/serverless-redis-http:latest
    environment:
      SRH_MODE: env
      SRH_TOKEN: example_token
      SRH_CONNECTION_STRING: 'redis://redis:6379'
    ports:
      - '8079:80'

上記のように設定すると、サーバレスRedisへのHTTPリクエストがserverless-redis-httpのサーバーに届き、その後、同じDockerのネットワーク上のRedisに対してリクエストが届く。つまり、サーバーレスRedisのようにローカルの開発環境でも開発が可能になる。

あとは、いつものようにvite devでサーバーを起動して画面から操作をしてみるだけ。

※ローカル環境でCloudflare WokersにDeployした際の状態をエミュレートして動作確認できるwranglerを利用した場合でも、同じような動作することが確認できる。

Cloudfare WorkersにDeploy後にUpstash Redisに接続できることを確認してみる

Wokersと言いつつもPages Functionなんだが、GitHubと連携してビルドコマンドの設定などをすればすぐにDeployが完了する。Deploy後に操作をしてみると、以下の動画のようにUpstash Redisに接続して動いていることが確認できる。

※Cloudflare Pagesで簡単にDeployする方法はCloudflare PagesでホスティングしてIP制限ありでSPAを公開してみたなども参考になると思う。SPAと書かれているがSveltKitの@sveltejs/adapter-cloudflareでビルドするので特に気にせず同じようにDeployの設定が簡単にできる。注意点としてはビルド後のファイルのディレクトリ指定くらいで、.svelte-kit/cloudflareにするのを間違えないようにする。

まとめ

Cloudflare KVは書き込みを多くする場合には課題が出てくるが、Redisならそんなことはないと思うので、Upstash Redisを利用することでサーバーレスのアプリケーションの開発の幅が広がるような気がした。

また、こういう外部のサービスに依存した開発をする場合、ローカルの開発環境で本番のサービス(Upstash RedisやFirebaseなど)に接続して開発するのか?という問題が出てくるが、本記事で見たようにローカルの開発環境で閉じた世界で容易に開発できる状態なので、PoCでのプロダクト開発などではいい選択肢になるのではないかと感じた。

以下、Cloudflare KVの公式からの引用。

KV is optimized for high-read applications. It stores data centrally and uses a hybrid push/pull-based replication to store data in cache. KV is suitable for use cases where you need to write relatively infrequently, but read quickly and frequently. Infrequently read values are pulled from other data centers or the central store, while more popular values are cached in the data centers they are requested from.
(KVは高リード・アプリケーション向けに最適化されている。データを一元的に保存し、プッシュ/プルベースのハイブリッド・レプリケーションを使用してデータをキャッシュに保存します。KVは、書き込みは比較的頻繁ではないが、読み取りは迅速かつ頻繁に行う必要があるようなユースケースに適している。読み取り頻度の低い値は他のデータセンターまたはセントラルストアからプルされ、利用頻度の高い値は要求元のデータセンターにキャッシュされます。)
cf. https://developers.cloudflare.com/kv/learning/how-kv-works/#write-data-to-kv-and-read-data-from-kv

おまけ

トラブルシューティング

@sveltejs/adapter-cloudflareのビルド時にエラー

@upstash/redisを利用していたが、@sveltejs/adapter-cloudflareでビルドをすると以下のようにエラーになった。

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/study/workspace/cat-faq-bot/node_modules/crypto-js/enc-hex' imported from /home/study/workspace/cat-faq-bot/node_modules/@upstash/redis/chunk-KBK5EDMP.mjs
Did you mean to import crypto-js/enc-hex.js?
    at new NodeError (node:internal/errors:405:5)
    at finalizeResolution (node:internal/modules/esm/resolve:324:11)
    at moduleResolve (node:internal/modules/esm/resolve:943:10)
    at defaultResolve (node:internal/modules/esm/resolve:1129:11)
    at nextResolve (node:internal/modules/esm/loader:163:28)
    at ESMLoader.resolve (node:internal/modules/esm/loader:835:30)
    at ESMLoader.getModuleJob (node:internal/modules/esm/loader:424:18)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/module_job:77:40)
    at link (node:internal/modules/esm/module_job:76:36)
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: 'ERR_MODULE_NOT_FOUND'
}

GitHubにもissueがすでに上がっているが、おそらくES Moduleはファイル拡張子が必要というルールに則ってないためにエラーになっていると思われる。今回、すぐに試したかったという事もあり、しょうがないので自分で雑にサーバーレスのRedis操作用のクラスを書き起こした(といっても@upstash/redisのコードの丸パクリだが…←MITライセンスであることを確認上丸パクリしてます)。

※追記
修正版がリリースされたようなので、以下のような自前実装は不要です。

https://github.com/upstash/upstash-redis/releases/tag/v1.25.1

実装は以下。この実装であれば特にライブラリに依存していないのでビルド時にエラーになることもなく実装できた。

src/lib/server/temporary-@upstash-redis.js
export default class Redis {
	constructor({ url, token }) {
		this.url = url;
		this.token = token;
		// https://github.com/upstash/upstash-redis/blob/v1.25.0/pkg/http.ts#L139
		this.retry = {
			attempts: 5,
			backoff: (retryCount) => Math.exp(retryCount) * 50
		};
	}

	async incr(key) {
		const data = await this.#retryFetch(JSON.stringify(this.#command('incr', key)));
		return data;
	}

	// https://github.com/upstash/upstash-redis/blob/v1.25.0/pkg/http.ts#L168
	async #retryFetch(reqBody) {
		let res = null;
		let fetchError = null;

		for (let i = 0; i <= this.retry.attempts; i += 1) {
			try {
				// eslint-disable-next-line no-await-in-loop
				res = await fetch(this.url, {
					method: 'POST',
					body: reqBody,
					headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' }
				});
				break;
			} catch (err) {
				fetchError = err;
				// eslint-disable-next-line no-await-in-loop, no-promise-executor-return
				await new Promise((r) => setTimeout(r, this.retry.backoff(i)));
				console.log('retrying...', i);
			}
		}

		if (!res) throw fetchError ?? new Error('Exhausted all retries');

		const { result, error } = await res.json();
		if (!res.ok) throw new Error(`${error}, command was: ${JSON.stringify(reqBody)}`);
		if (error) throw new Error(error);
		if (typeof result === 'undefined') throw new Error('Request did not return a result');

		return this.#deserialize(result);
	}

	#command(command, ...args) {
		return [command, ...args].map((c) => this.#serialize(c));
	}

	// https://github.com/upstash/upstash-redis/blob/v1.25.0/pkg/commands/command.ts#L8
	// eslint-disable-next-line class-methods-use-this
	#serialize(c) {
		switch (typeof c) {
			case 'string':
			case 'number':
			case 'boolean':
				return c;
			default:
				return JSON.stringify(c);
		}
	}

	// https://github.com/upstash/upstash-redis/blob/v1.25.0/pkg/commands/command.ts#L52
	// https://github.com/upstash/upstash-redis/blob/v1.25.0/pkg/util.ts#L1
	#deserialize(result) {
		try {
			// Try to parse the response if possible
			return this.#parseRecursive(result);
		} catch {
			return result;
		}
	}

	#parseRecursive(obj) {
		const parsed = Array.isArray(obj)
			? obj.map((o) => {
					try {
						return this.#parseRecursive(o);
					} catch {
						return o;
					}
			  })
			: JSON.parse(obj);

		/**
		 * Parsing very large numbers can result in MAX_SAFE_INTEGER
		 * overflow. In that case we return the number as string instead.
		 */
		if (typeof parsed === 'number' && parsed.toString() !== obj) {
			return obj;
		}
		return parsed;
	}
}
脚注
  1. 質問・回答のセットと、その質問・回答からタグを作ったものをDBに保存しておき、ユーザーからの質問からタグを生成し、そのタグの一致度が高いものを回答の候補にする。その回答の候補からユーザーへの質問に最もよいと思えるものをChatGPTに聞き、ChatGPTの応答をBotの応答として返す。 ↩︎

Discussion