Cloudflare Durable Objects

LeeWonjin·2025년 2월 21일
1

Serverless stateful backend를 구현할 수 있는 서비스.
Workers의 일종이고, Workers Paid Plan이어야 사용 가능함.
5$기본 요금이 있는데, 여기에 durable objects기본 사용량도 포함되어있음.

사용사례로 실시간 채팅, 멀티플레이 게임을 든다.

EC2컴퓨팅 돌리듯이 시간당 청구되는 옵션이 있으니, 다 썼으면 잘 끄고 다녀야한다.
특히, 연결된 스토리지를 비우지 않으면(deleteAll()) 꺼지지 않으니 주의 필요.

공식문서: https://developers.cloudflare.com/durable-objects/

개요

프로젝트 클론, 실행, 배포

wrangler는 cloudflare workers CLI다.

# 예제 프로젝트 클론
npm create cloudflare@latest -- durable-object-starter
cd durable-object-starter

# 로컬에서 돌려보기
npx wrangler dev

# 클라우드플레어 workers에 배포하기
npx wrangler deploy

로컬호스트 서버 glibc 이슈

npx wrangler dev가 특정 버전의 glibc를 요구하는데,
현재 우분투 20.04 LTS 기반의 OS를 쓰는 본인은 이를 충족하지 못함(3.31).

wrangler의 3.2버전부터는 glibc 3.31도 지원한다고 되어있으나,
npm에서 다른 버전의 wrangler를 설치해도 문제가 지속됨

사실상 OS를 적절한 glibc를 사용하는 버전으로 재설치하는 것이 유일한 해결책인 것으로 판단했음.

현재는 급한대로 deploy해서 직접 클라우드플레어에 접근함.
배포시간이 그렇게 오래걸리지도 않고 무료사용량도 있으니 별 문제가 되지않음.

wrangler.jsonc

durable objects 바인딩, 마이그레이션 설정은 여기서 한다.
bindings[1]내용은 "index.js의 MyDurableObject클래스를 durable objects로 배포할건데, worker에서는 이걸 env.MY_DURABLE_OBJECT라는 이름으로 접근하겠다"와 같다.

"migrations": [
    {
      "new_classes": [
        "MyDurableObject"
      ],
      "tag": "v1"
    }
  ],
  "durable_objects": {
    "bindings": [
      {
        "class_name": "MyDurableObject",
        "name": "MY_DURABLE_OBJECT"
      }
    ]
  },

src/index.js

크게 2개 섹션이 있다.

  • Durable Object를 정의
    • public method는 외부에서 RPC로 호출가능하다.
  • Worker:
    • 클라이언트 요청 받고
    • 정의하고 배포한 Durable Object의 메소드 invoke(호출)해서
    • 클라이언트에게 응답주기

worker의 fetch()에서 await stub.fetch('url')하는 부분이 듀러블 오브젝트 메소드를 호출하는 것임.

  • 이 url은 정확히 url form을 따라야 함
  • 그러나 실제로 존재하는(resolvable한) url일 필요는 없음.
import { DurableObject } from 'cloudflare:workers';

// Durable Object
export class MyDurableObject extends DurableObject {
	constructor(ctx, env) {
		super(ctx, env);
	}

	hello(name) {
		return new Response(`Hello, ${name}!`);
	}

	async fetch(request) {
		const url = new URL(request.url);
		let name = url.searchParams.get('name');
		if (!name) {
			name = 'World';
		}

		switch (url.pathname) {
			case '/hello':
				return this.hello(name);
			default:
				return new Response('Bad Request', { status: 400 });
		}
	}
}

// Worker
export default {
	async fetch(_request, env, _ctx) {
		const id = env.MY_DURABLE_OBJECT.idFromName('foo');
		const stub = env.MY_DURABLE_OBJECT.get(id);
		let response = await stub.fetch('http://do/hello?name=World');

		return response;
	},
};

Storage API

스토리지 유형

Durable objects는 두 가지 유형의 스토리지 제공

  • KV : key-value 스토리지
  • SQLite : sql 스토리지 - 아직 베타임

둘 중 하나만 고를 수 있고, 한 번 고르면 변경할 수 없음.

스토리지를 왜 쓰는가

기본적으로 durable object는 in-memory state를 가질 수 있다.
그러나 요청이 없는 durable object는 메모리에서 제거될 수 있고, 이 때 인메모리 상태도 모두 제거된다. (상태를 잃어버린다.)

그래서 상태가 보존되어야 한다면 아래와 같이할 수 있다.

  • Storage API를 이용해 데이터를 저장
  • durable object를 새로 초기화 할 때 스토리지에서 데이터 꺼내서 인메모리 상태(멤버변수)에 넣기

스토리지->메모리 상태 불러오기

import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
  value: number;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);

    // `blockConcurrencyWhile()` ensures no requests are delivered until
    // initialization completes.
    ctx.blockConcurrencyWhile(async () => {
      // After initialization, future reads do not need to access storage.
      this.value = (await ctx.storage.get("value")) || 0;
    });
  }

  async getCounterValue() {
    return this.value;
  }
}

스토리지 비우기

ctx.storage.deleteAll()안하면 스토리지가 비워지지 않은것으로 간주
durable object도 제거되지 않고 계속 살아있다.
그러면 돈도 줄줄 샌다. 명시적으로 스토리지를 잘 비워서 우리의 지갑을 지키자

아래 예제에서는 deleteAll이 호출되고 durable object가 소멸되는데 30초정도 걸리는듯.

간단한 숫자 in/decrease 예제

아래 4개의 URL로 접속해서 조작

  • <worker endpoint>/increase
  • <worker endpoint>/decrease
  • <worker endpoint>/view
  • <worker endpoint>/cleaer : deleteAll
import { DurableObject } from 'cloudflare:workers';

// Durable Object
export class MyDurableObject extends DurableObject {
	cnt;

	constructor(ctx, env) {
		super(ctx, env);
		ctx.blockConcurrencyWhile(async () => {
			this.cnt = (await ctx.storage.get('cnt')) || 0;
		});
	}

	async increase() {
		this.cnt++;
		await this.ctx.storage.put('cnt', this.cnt);
		return new Response(JSON.stringify({ cnt: this.cnt }));
	}

	async decrease() {
		this.cnt--;
		await this.ctx.storage.put('cnt', this.cnt);
		return new Response(JSON.stringify({ cnt: this.cnt }));
	}

	view() {
		return new Response(JSON.stringify({ cnt: this.cnt }));
	}

	async clear() {
		await this.ctx.storage.deleteAll();
		return new Response('Storage Cleared');
	}

	async fetch(request, ctx) {
		const url = new URL(request.url);

		switch (url.pathname) {
			case '/increase':
				return this.increase();
			case '/decrease':
				return this.decrease();
			case '/view':
				return this.view();
			case '/clear':
				return this.clear();
			default:
				return new Response('Bad Request', { status: 400 });
		}
	}
}

// Worker
export default {
	async fetch(_request, env, _ctx) {
		const url = new URL(_request.url);

		const id = env.MY_DURABLE_OBJECT.idFromName('foo');
		const stub = env.MY_DURABLE_OBJECT.get(id);
		let response = await stub.fetch(`http://do${url.pathname}`);

		return response;
	},
};
profile
노는게 제일 좋습니다.

0개의 댓글