코드 한 줄씩 읽기
Cloudflare Containers · Sandbox 실습 코드 해설

「Kent C. Dodds 사례로 배우는 Cloudflare Containers」 교재의 번외편입니다. Lab A · Lab B에서 실제로 돌려 본 파일 전체를 한 줄씩 설명합니다.

대상: C는 잘 알지만 wrangler 설정 · TypeScript · Dockerfile은 처음인 분 — 파란 상자 C로 치면에 C 대응을 적었습니다 · 기준 2026-11-09 (KST), @cloudflare/sandbox 1.0.3 · @cloudflare/containers 0.3.7

☰ 목차

0. 읽는 법 · 파일 지도

각 파일은 왼쪽 = 실제 코드(줄 번호), 오른쪽 = 그 줄의 설명으로 되어 있습니다. 회색 줄은 빈 줄·닫는 괄호처럼 따로 설명할 것이 없는 줄입니다. 본문의 index.ts 41 같은 링크를 누르면 해당 줄로 이동해 깜빡입니다.

처음이라면 이 순서를 권합니다: 1장 문법을 훑고 → 3장 타임라인을 Lab B로 한 단계씩 넘기며 "코드 보기"를 눌러 따라가기 → 4장 · 5장을 처음부터 읽기.

어떤 파일이 어디서 실행되나

① 내 Mac (개발) package.json — 설치할 패키지 tsconfig.json — TS 검사 규칙 worker-configuration.d.ts env 타입 (wrangler types가 생성) .dev.vars — 로컬 Secret .env.production — 운영 Secret npx tsc --noEmit → 문법·타입 검사 npx wrangler dev → 로컬 실행 ② wrangler deploy wrangler.jsonc 읽기 src/*.ts → 번들 Dockerfile → 이미지 빌드 후 업로드 ③ Cloudflare (실행) Worker — src/index.ts fetch() · queue() (요청·메시지가 올 때마다) Durable Object — 컨테이너 관리인 Lab B: src/audio-sandbox.ts (AudioSandbox) Lab A: src/index.ts 안 JobContainer 컨테이너 — Dockerfile로 만든 이미지 Lab B: ffmpeg + sandbox-shim (서버 없음) Lab A: node server.mjs (HTTP 서버 8080)

C 툴체인과 비교하면

이 프로젝트C로 치면차이
src/*.ts*.c기계어가 아니라 JavaScript로 바뀌어 V8 엔진에서 실행
worker-configuration.d.ts자동 생성 헤더 *.h선언만 있고 구현은 런타임에 있음
npx tsc --noEmitgcc -fsyntax-only -Werror출력 파일 없이 검사만
tsconfig.jsonCFLAGS
package.json + npm installMakefile의 의존성 + 패키지 관리자라이브러리가 프로젝트 안 node_modules/에 설치됨
wrangler 번들정적 링크import를 따라가 파일 하나로 합침
wrangler.jsonc링커 스크립트 + 배포 설정심볼(클래스·함수)을 자원(DO·큐·버킷)에 연결
Dockerfile루트 파일시스템을 만드는 빌드 스크립트결과물이 이미지 — 컨테이너의 /
.dev.vars / Secretexecve()의 envpWorker는 getenv() 대신 env.X로 읽음
바인딩 env.AUDIO 등부모가 미리 열어 넘겨준 fd경로·키로 직접 열 수 없고, 받은 핸들만 씀
export default { fetch, queue }struct file_operationsmain()이 없음. 런타임이 이벤트마다 등록된 함수를 부름
핵심 한 줄. 컨테이너는 인터넷 주소가 없습니다. 항상 Worker → Durable Object → 컨테이너 순서로만 닿습니다. Worker는 요청·메시지를 받는 입구, DO는 컨테이너를 켜고 끄는 관리인, 컨테이너는 ffmpeg가 실제로 도는 곳입니다.

1. C 프로그래머를 위한 TypeScript

TypeScript(TS) = JavaScript + 타입 표기. tsc가 컴파일 때 타입을 검사하고, 실행될 때는 타입이 모두 지워진 JavaScript가 돕니다. if · for · while · 연산자는 C와 거의 같으므로 생략하고, 다른 점만 모았습니다.

C에 없는 다섯 가지 — 먼저 읽기

① 이벤트 루프와 async / await

스레드는 하나이고, 블로킹 read() 같은 함수가 없습니다. 모든 I/O는 "요청만 걸고 바로 돌아오는" 방식입니다.
await p = "이 함수만 여기서 잠시 빠져나가고, p가 끝나면 다음 줄부터 이어서". C로 직접 쓰면 epoll 루프 + 콜백 + 상태 저장 구조체가 필요한 코드를, 컴파일러가 상태 머신으로 대신 만들어 줍니다.
async 함수는 항상 Promise(결과를 나중에 받을 수 있는 핸들, future와 같음)를 돌려줍니다.
// C라면: read 요청 → 콜백에서 다음 단계 → 또 콜백 …
const obj = await env.AUDIO.get(key)   // 여기서 양보, 결과 오면 재개
const text = await obj.text()          // 또 양보

② 클로저

화살표 함수 (x) => …는 함수 포인터와 같지만, 바깥 지역 변수를 붙잡고 다닙니다. 함수가 끝난 뒤에도 그 변수는 살아 있습니다 (C라면 void *ctx로 직접 넘겨야 하는 것).
const id = job.id
runJob(job).finally(() => active.delete(id))
// 나중에 실행돼도 id를 기억함

③ GC와 참조

malloc/free가 없습니다. 더 이상 아무도 가리키지 않는 객체는 GC가 회수합니다.
객체·배열을 담은 변수에는 항상 포인터가 들어 있다고 보면 됩니다. 함수에 넘겨도 복사되지 않고, 받은 쪽에서 바꾸면 원본이 바뀝니다. 복사하려면 { ...obj }(얕은 memcpy).

④ 타입은 실행 시 사라짐

sizeof, 메모리 레이아웃, 정수 크기가 없습니다. 숫자는 number(= double) 하나뿐이고 큰 정수는 bigint(≈ uint64_t).
x as Job은 캐스트처럼 보이지만 아무 검사·변환도 하지 않습니다. 네트워크에서 받은 JSON이 정말 Job 모양인지는 실행 중에 직접 확인해야 합니다.

⑤ 예외

오류 코드를 반환하는 대신 throw로 던지면 catch가 있는 곳까지 호출 스택을 거슬러 올라갑니다(setjmp/longjmp와 비슷하지만 안전). await을 건너서도, Lab B처럼 RPC를 건너 다른 DO에서 던진 오류도 그대로 전달됩니다.
finally = 언어가 보장하는 goto cleanup.

번외: main()이 없다

Worker 파일은 위에서 아래로 한 번 실행되며 함수를 정의·등록할 뿐입니다. 이후에는 런타임이 요청·메시지가 올 때마다 export default에 등록된 fetch / queue를 부릅니다. 리눅스 드라이버가 file_operations를 등록하고 커널이 부르기를 기다리는 구조와 같습니다.

값 · 함수

문법뜻C로 치면이 코드에서
const x = … / let재대입 불가 / 가능int *const p처럼 변수만 고정. 가리키는 객체의 내용은 바꿀 수 있음audio-sandbox.ts 6
'…' `…${x}…`문자열, 값 끼워 넣기불변 문자열. ${} ≈ snprintf. ===는 strcmp처럼 내용 비교index.ts 47
{ a: 1, b }객체필드를 실행 중에 더할 수 있는 struct(해시맵에 가까움). b만 쓰면 .b = baudio-sandbox.ts 63
[a, b] · .map() · .join()배열과 메서드길이 가변 배열. 범위 밖 읽기는 crash가 아니라 undefinedaudio-sandbox.ts 9
(x) => x + 1화살표 함수함수 포인터 + 캡처(②)server.mjs 24
async · await비동기 함수 · 결과 기다리기C에 없음 (①)index.ts 48
Promise.all([p1, p2])동시에 시작, 모두 대기aio_read 두 개 + aio_suspendaudio-sandbox.ts 29
const [a, b] = … · const { size } = …구조 분해a = r[0]; b = r[1]; / size = r.size;를 한 줄에audio-sandbox.ts 58
{ ...obj, x: 1 }펼치기얕은 memcpy 후 필드 추가index.ts 49
a ?? ba가 없으면 ba != NULL ? a : b (0이나 빈 문자열은 그대로 a)index.ts(A) 7
null / undefined값 없음둘 다 NULL 비슷. undefined = "아예 대입된 적 없음"audio-sandbox.ts 30
try / catch / finally · throw예외 처리setjmp/longjmp + goto cleanup (⑤)index.ts 44–55
for (const m of list)항목 순회for (i = 0; i < n; i++) { m = list[i]; … }index.ts 42
/^[\w-]+$/.test(s)정규식regcomp + regexec를 문법으로 내장index.ts(A) 68

모듈 · 클래스

문법뜻C로 치면이 코드에서
import { A } from 'x'가져오기#include + 링크. './파일'=내 소스, '@cloudflare/…'=라이브러리, 'cloudflare:workers'·'node:http'=런타임 내장(libc 자리)audio-sandbox.ts 1–2
export · export default내보내기 · 대표 하나static이 아닌 외부 심볼 · 런타임이 찾는 진입 심볼index.ts 11
class A extends B상속B를 첫 멤버로 품은 struct + vtableaudio-sandbox.ts 15
constructor · super()생성 시 실행 · 부모 먼저init() 함수 · 부모 init 먼저 호출audio-sandbox.ts 19
this.x이 인스턴스의 x숨겨진 첫 인자 self->xaudio-sandbox.ts 22
private readonly x: T클래스 전용, 한 번만 대입init에서만 채우는 const 멤버 (private은 컴파일 검사만)audio-sandbox.ts 16
override m() · static x부모 메서드 교체 · 클래스 자체의 값vtable 슬롯 교체 · 전역 static 변수index.ts(A) 28–31

타입 표기 (컴파일 때만 존재)

문법뜻C로 치면이 코드에서
x: stringx는 문자열const char *x. 순서만 반대(이름 뒤에 타입)index.ts 5
type Job = { a: string; b?: number }모양에 이름 붙이기typedef struct. ? = 없어도 되는 필드. 레이아웃·sizeof 없음audio-sandbox.ts 4
Promise<T> · MessageBatch<Job>제네릭C++ 템플릿. void * 대신 타입이 검사되는 컨테이너index.ts 41
Record<string, unknown>아무 키·값 객체char* 키 해시맵. unknown ≈ 쓰기 전에 확인해야 하는 void *index.ts 5
값 as TT로 간주캐스트처럼 보이지만 비트도 안 바뀌고 검사도 없음 (④)index.ts(A) 18
값!null 아님 보장assert(p != NULL)인데 실행 시 검사 없음audio-sandbox.ts 61
obj satisfies T규칙에 맞는지 검사_Static_assert류index.ts 58
import { type Job }타입만 가져옴헤더만 #include, 코드 생성 없음index.ts 1
server.mjs는 TS가 아니라 순수 JavaScript(.mjs)라 : 타입 표기가 없습니다. 컨테이너 안 Node가 그대로 실행합니다.

2. queue()는 누가 부르나

코드 어디에도 queue(...)를 부르는 줄이 없습니다. fetch()도 마찬가지입니다. 두 함수는 Cloudflare 런타임이 대신 불러 주는 함수(핸들러)입니다. queue()가 불리려면 아래 네 가지가 모두 있어야 합니다.

① 설정: consumers

wrangler.jsonc의 queues.consumers에 큐 이름이 있음 → wrangler.jsonc 51–59

② 코드: export default의 queue

Worker 진입점 객체에 queue 함수가 있음 → index.ts 41

③ 배포: 소비자 등록

wrangler deploy가 이 Worker를 큐의 소비자로 등록. (그래서 Worker를 지우기 전에 queues consumer remove가 필요했음)

④ 메시지

누군가 env.JOBS.send()로 넣은 메시지가 있음 → index.ts 34
C로 치면 signal(SIGUSR1, handler)로 핸들러를 등록해 두면 커널이 신호가 올 때 부르는 것과 같습니다. 등록은 ①·③이 하고, "신호"는 ④의 메시지입니다. 차이는 한 번에 여러 메시지를 묶어(batch) 준다는 점입니다.

부르는 규칙

상황런타임이 하는 일
메시지가 쌓임3개가 모이거나(max_batch_size) 첫 메시지 후 2초가 지나면(max_batch_timeout) 그 묶음으로 queue(batch, env) 한 번 호출
m.ack()그 메시지는 끝. 다시 주지 않음
m.retry({ delaySeconds: 10 })10초 뒤 같은 메시지를 다시 줌. m.attempts가 1 → 2 → 3 → 4로 늘어남
ack/retry 없이 queue()가 정상 종료표시하지 않은 메시지도 모두 성공으로 처리
queue() 밖으로 오류가 빠져나감표시하지 않은 메시지 전부 재시도 (그래서 실습 코드는 메시지마다 try/catch)
시도가 max_retries(3)를 넘음첫 시도 + 재시도 3 = 4번 실패 후 dead_letter_queue로 이동. 로그: Moving message … to dead letter queue … after 4 failed attempts
같은 메시지가 두 번 올 수 있습니다(at-least-once). 그래서 Lab A 컨테이너는 처리 중인 id를 Set에 담아 중복을 막고(server.mjs 80), Lab B는 작업마다 새 DO를 만들어 서로 섞이지 않게 합니다.
Lab A와 Lab B의 결정적 차이. Lab A의 queue()는 컨테이너에 일을 넘기고 202만 받으면 바로 ack() — ffmpeg는 그 뒤 백그라운드에서 돕니다. Lab B의 queue()는 await sb.stitch(job)로 ffmpeg가 끝날 때까지 기다린 뒤 ack합니다. 그래서 Lab B는 실패하면 큐가 자동으로 재시도해 주지만, 작업 시간은 queue() 한 번의 실행 시간 상한(문서 기준 최대 15분) 안에 끝나야 합니다.

3. 호출 타임라인

세로 = 시간 순서, 가로 = 코드가 실행되는 자리. 화살표 하나가 "누가 누구를 부르는지"입니다. 다음 ▶을 누르거나 화살표를 클릭하면 설명이 나오고, 코드 보기로 그 줄로 이동합니다. 시간 표시는 실습 로그(로컬 wrangler dev)에서 실제로 찍힌 값입니다.

배치 시뮬레이터 — 메시지를 언제 보내면 몇 번 불리나

규칙(max_batch_size: 3, max_batch_timeout: 2)만 따른 단순 모델입니다. 실제로는 네트워크 지연 등으로 경계에서 묶음이 달라질 수 있습니다 — Lab 5에서 curl 3번을 빠르게 보냈을 때 3개 한 묶음으로 올 때도, 2개 + 1개로 나뉠 때도 있었던 이유입니다.

함수별 호출 요약

함수누가 부르나언제코드
fetch(req, env)런타임HTTP 요청이 올 때마다B index.ts 12 · A index.ts 62
queue(batch, env)런타임메시지가 3개 모이거나 2초 지났을 때, 재시도 때B index.ts 41 · A index.ts 99
DO constructor런타임그 이름의 DO가 처음 메모리에 올라올 때 (stub 메서드를 처음 부를 때)B audio-sandbox.ts 19
stitch(job)내 queue() (RPC)메시지 하나마다B audio-sandbox.ts 27
startAndWaitForPorts()내 코드컨테이너가 필요할 때 (켜져 있으면 바로 통과)A index.ts 53
onStart / onStop / onErrorContainer 부모 클래스컨테이너 시작·종료·오류A index.ts 29–31
onActivityExpired()Container 부모 클래스sleepAfter(1분) 동안 들어온 요청이 없을 때A index.ts 35
onCallback(req, env)런타임 (outboundByHost)컨테이너가 http://callback.internal/…로 요청할 때A index.ts 5
createServer의 함수Node (컨테이너 안)8080으로 요청이 들어올 때마다A server.mjs 62
runJob(job)server.mjs의 /jobs작업 접수 직후, 기다리지 않고 백그라운드로A server.mjs 82
process.on('SIGTERM')플랫폼 → OS 신호stop()으로 컨테이너를 끌 때A server.mjs 92

4. Lab B 파일 해설 Sandbox 1.0

프로젝트 폴더 ~/Labs/container/audio-sandbox. 읽는 순서: 설정(wrangler.jsonc) → 이미지(Dockerfile) → DO(audio-sandbox.ts) → Worker(index.ts) → 개발 도구 파일.

Lab Baudio-sandbox/wrangler.jsonc배포 설정 (wrangler가 읽음)
1{
2 "$schema": "node_modules/wrangler/config-schema.json",
$schema = 편집기(VS Code)가 자동 완성·오류 표시에 쓰는 규칙 파일 위치. 배포에는 영향 없음.
3 "name": "audio-sandbox",
4 "account_id": "<ACCOUNT_ID>",
5 "main": "src/index.ts",
Worker 이름 · 계정 · 시작 파일. Lab A와 같은 의미.
6 "compatibility_date": "2026-11-04",
7 "compatibility_flags": [
8 "nodejs_compat"
9 ],
이 프로젝트를 만든 날의 런타임 기준 + Node 호환 스위치.
10 "observability": {
11 "enabled": true
12 },
대시보드 로그 켜기.
13 "containers": [
14 {
15 "class_name": "AudioSandbox",
16 "scheduling_policy": "durable_object",
containers: AudioSandbox DO가 컨테이너를 씁니다.
scheduling_policy: "durable_object" = DO 인스턴스 1개당 컨테이너 1대를 붙이는 방식. 작업마다 새 DO를 만들면 새 컨테이너가 생깁니다. (로컬 dev에서 쓰려면 wrangler 4.136 이상)
C로 치면 containers + durable_objects + exports는 링커 스크립트처럼 "어떤 심볼(클래스)을 어떤 자원에 붙일지"를 적는 곳입니다.
17 "images": {
18 "ffmpeg": {
19 "dockerfile": "./container/Dockerfile"
20 }
21 }
images = 이름 붙인 이미지 목록. 여기서 붙인 이름 ffmpeg가 코드의 c.images.ffmpeg가 됩니다. 이미지를 여러 개 두고 골라 켤 수도 있습니다.
22 }
23 ],
containers 목록 끝.
24 "durable_objects": {
25 "bindings": [
26 {
27 "name": "AUDIO_SANDBOX",
28 "class_name": "AudioSandbox"
29 }
30 ]
31 },
DO 바인딩: 코드에서 env.AUDIO_SANDBOX로 AudioSandbox 인스턴스를 얻습니다.
C로 치면 바인딩 = 부모 프로세스가 미리 open()해서 넘겨준 fd. 코드는 경로·키로 직접 열 수 없고, 받은 핸들(env.X)만 씁니다.
32 "exports": {
33 "AudioSandbox": {
34 "type": "durable-object",
35 "storage": "sqlite"
36 }
37 },
exports = Lab A의 migrations를 대신하는 새 방식. "AudioSandbox는 SQLite 저장소를 가진 DO"라고 선언만 하면 되고, 태그 이력을 관리할 필요가 없습니다.
38 "r2_buckets": [
39 {
40 "binding": "AUDIO",
41 "bucket_name": "audio-lab"
42 }
43 ],
R2 바인딩 env.AUDIO → 버킷 audio-lab. Lab B는 이 바인딩만으로 R2를 읽고 쓰므로 R2 API 키가 필요 없습니다.
C로 치면 env.AUDIO도 미리 열린 fd와 같습니다. 그래서 R2 접속 키가 코드 어디에도 없습니다.
44 "queues": {
45 "producers": [
46 {
47 "binding": "JOBS",
48 "queue": "audio-jobs"
49 }
50 ],
producer: env.JOBS.send() → audio-jobs 큐.
51 "consumers": [
52 {
53 "queue": "audio-jobs",
54 "max_batch_size": 3,
55 "max_batch_timeout": 2,
56 "max_retries": 3,
57 "dead_letter_queue": "audio-jobs-dlq"
58 }
59 ]
consumer: Lab A와 같은 규칙(3개씩 / 2초 / 재시도 3회 / DLQ). 이 블록이 있어서 런타임이 index.ts의 queue()를 불러 줍니다.
C로 치면 C로 치면 "이 큐에 메시지가 오면 이 실행 파일의 queue 콜백을 불러라"라는 등록표. signal(SIGUSR1, handler)를 설정 파일로 하는 셈입니다.
60 }
61}
파일 끝.
Lab Baudio-sandbox/container/Dockerfile컨테이너 이미지
1FROM node:24-trixie-slim
출발점: Node 24가 든 Debian(trixie) 경량판. Lab B는 Node를 쓰지 않지만, 익숙한 Debian 계열이면 무엇이든 됩니다(Sandbox 1.0은 베이스 이미지 제한 없음).
C로 치면 FROM = 출발점 루트 파일시스템(chroot 이미지). 이후 줄은 그 위에 레이어를 쌓습니다.
2RUN apt-get update \
3 && apt-get install -y --no-install-recommends ffmpeg ca-certificates \
4 && rm -rf /var/lib/apt/lists/*
ffmpeg와 인증서 설치, 캐시 정리.
5# Files 클래스가 컨테이너 안에서 쓰는 도우미. 태그 = 설치한 @cloudflare/sandbox 버전
6COPY --from=docker.io/cloudflare/sandbox:1.0.3 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim
sandbox-shim 복사. COPY --from=이미지는 다른 이미지 안의 파일 하나만 가져오는 문법. 코드의 Files(writeFile/readFile/stat…)가 컨테이너 안에서 이 도구를 exec로 불러 파일을 다룹니다. 태그(1.0.3)는 설치한 @cloudflare/sandbox 버전과 같아야 합니다.
C로 치면 다른 빌드 결과물에서 정적 바이너리 하나만 복사해 오는 것과 같습니다.
7# 고정 음원은 이미지에 굽기 (Kent와 동일)
8COPY assets /opt/assets
인트로·간주·아웃트로 mp3 3개를 이미지에 넣음 → /opt/assets/.
9RUN mkdir /workspace
작업 폴더의 부모 /workspace를 미리 만들어 둠.
10# 서버 없음! 켜져 있기만 하면 됨 (명령은 exec로 실행)
11CMD ["sleep", "infinity"]
서버 없음. sleep infinity = 아무것도 안 하고 켜져만 있음. 실제 일은 DO가 exec()로 명령을 넣어 시킵니다.
C로 치면 int main(void){ for(;;) pause(); } — 아무 일도 안 하고 살아만 있는 PID 1.
Lab Baudio-sandbox/src/audio-sandbox.tsDurable Object (Cloudflare)
1import { Files } from '@cloudflare/sandbox'
npm 패키지 @cloudflare/sandbox에서 Files 클래스만 가져옴.
C로 치면 import ≈ #include + 링크. 단 헤더/구현 분리 없이 이름(심볼) 단위로 가져옵니다.
2import { DurableObject } from 'cloudflare:workers'
cloudflare:workers = npm이 아니라 Workers 런타임이 기본으로 주는 모듈. 그 안의 DurableObject가 내 DO의 부모 클래스.
C로 치면 <unistd.h>처럼 런타임(libc 자리)이 제공하는 모듈. npm으로 설치하지 않습니다.
3
4export type Job = { draftId: string; callKey: string; responseKey: string }
큐 메시지 모양 정의 + export(index.ts에서도 씀). type은 TS 검사용이라 실행 파일에서는 사라집니다.
C로 치면 typedef struct { char *draftId; … } Job; 과 비슷하지만 메모리 레이아웃·sizeof가 없습니다. 큐로 보낼 때는 구조체 바이트가 아니라 JSON 비슷한 직렬화로 전달됩니다.
5
6const JOB_DIR = '/workspace/job'
컨테이너 안 작업 폴더 경로. const = 다시 대입하지 않는 값.
7// Kent의 필터를 단순화: 녹음 2개는 무음 제거 + 음량 맞춤, 5개를 1초 크로스페이드로 잇기
8const CLEAN = 'silenceremove=1:0:-50dB,silenceremove=stop_periods=-1:stop_duration=1:stop_threshold=-50dB,loudnorm=I=-16:LRA=11:TP=0.0'
ffmpeg 오디오 필터 문자열 (녹음 정리용):
· silenceremove=1:0:-50dB — 앞쪽 무음 제거
· silenceremove=stop_periods=-1:… — 중간·끝의 1초 넘는 무음 제거
· loudnorm=I=-16… — 음량을 −16 LUFS(팟캐스트 표준 근처)로 맞춤
9const FILTER = [
10 `[1]${CLEAN}[c]`, `[3]${CLEAN}[r]`,
11 '[0][c]acrossfade=d=1:c2=nofade[a1]', '[a1][2]acrossfade=d=1:c1=nofade[a2]',
12 '[a2][r]acrossfade=d=1:c2=nofade[a3]', '[a3][4]acrossfade=d=1:c1=nofade[out]',
13].join(';')
필터 그래프. 입력 번호: [0]인트로 [1]질문 [2]간주 [3]답변 [4]아웃트로.
· [1]CLEAN[c]: 질문을 정리해 이름 c
· [3]CLEAN[r]: 답변 → r
· acrossfade=d=1: 앞뒤를 1초 겹쳐 잇기 → a1 → a2 → a3 → out
`…${CLEAN}…`은 문자열 안에 변수 끼워 넣기, .join(';')는 배열을 ;로 이어 한 문자열로.
C로 치면 `[1]${CLEAN}[c]` ≈ snprintf(buf, n, "[1]%s[c]", CLEAN). 메모리는 런타임이 알아서 잡습니다.
14
15export class AudioSandbox extends DurableObject<Env> {
내 Durable Object. extends DurableObject<Env> → this.env, this.ctx를 물려받음. <Env> 덕분에 this.env.AUDIO가 R2Bucket이라는 걸 TS가 압니다. wrangler.jsonc의 class_name과 이름이 같아야 합니다.
C로 치면 struct AudioSandbox { struct DurableObject base; … }처럼 부모를 첫 멤버로 품고, 메서드는 vtable로 붙는다고 보면 됩니다.
16 private readonly container: Container
17 private readonly files: Files
필드 선언. private = 클래스 밖에서 못 씀, readonly = 생성자에서 한 번만 대입, : Container = 타입.
C로 치면 초기화 후 바뀌지 않는 멤버. const 멤버를 생성자(init)에서만 채우는 것과 같습니다.
18
19 constructor(ctx: DurableObjectState, env: Env) {
20 super(ctx, env)
생성자. 이 DO 인스턴스가 처음 메모리에 올라올 때 런타임이 부릅니다(우리가 new 하지 않음). super(ctx, env)로 부모에게 먼저 넘겨야 this를 쓸 수 있습니다.
C로 치면 우리가 init()을 부르지 않습니다. 런타임이 객체를 만들 때 콜백처럼 부릅니다. super() = 부모의 init을 먼저 호출.
21 if (!ctx.container) throw new Error('No container is configured')
ctx.container는 wrangler.jsonc의 containers에 이 클래스가 연결돼 있을 때만 있습니다. 없으면 설정 실수이므로 바로 알림.
22 this.container = ctx.container
23 this.files = new Files(ctx.container) // 컨테이너 안 sandbox-shim을 exec로 부르는 도우미
컨테이너 손잡이를 저장하고, 그 위에 Files 도우미를 얹음.
24 }
25
26 // Worker가 RPC로 부르는 메서드 (stub에는 내가 정의한 메서드만 보임)
27 async stitch(job: Job): Promise<{ key: string; size: number }> {
stitch = Worker가 RPC로 부르는 메서드. 다른 Worker 코드에서 stub.stitch(job)처럼 일반 함수처럼 부르지만, 실제로는 네트워크 너머 DO에서 실행됩니다. Promise<{ key: string; size: number }> = "나중에 key·size를 돌려줌".
C로 치면 rpcgen을 떠올리면 정확합니다. Worker 쪽 sb는 client stub, 이 메서드는 서버 쪽 구현. 인자 job과 반환값은 XDR처럼 직렬화돼 오갑니다.
28 // ① R2 바인딩에서 입력 스트림 받기 (키·서명 URL 불필요). 없으면 컨테이너를 켜기 전에 실패
29 const [call, resp] = await Promise.all([this.env.AUDIO.get(job.callKey), this.env.AUDIO.get(job.responseKey)])
30 if (!call || !resp) throw new Error('input audio not found in R2')
① R2에서 두 녹음을 동시에 가져옴(Promise.all). const [call, resp]는 배열을 두 변수로 나눠 받기. 키가 없으면 null이 오므로 컨테이너를 켜기 전에 실패 처리 → 비용 0.
C로 치면 aio_read() 두 개를 걸고 둘 다 끝나길 기다리는 것과 같습니다. 없으면 NULL 반환 → 검사.
31
32 const c = this.container
짧게 쓰려고 c로 별칭.
33 if (!c.running) {
34 c.start({ image: c.images.ffmpeg, instance: 'lite', enableInternet: false }) // 인터넷 차단
꺼져 있으면 시작. image: wrangler의 images.ffmpeg, instance: 'lite': 가장 작은 크기, enableInternet: false: 인터넷 차단(외부와 통신할 일이 없으므로). start()는 기다리지 않고 바로 돌아옵니다.
C로 치면 fork()만 하고 wait()하지 않는 것처럼, 시작만 요청하고 바로 돌아옵니다.
35 await c.setInactivityTimeout(10 * 60 * 1000) // 안전장치: destroy를 못 해도 10분 뒤 종료
36 }
안전장치: 10분 동안 아무 활동이 없으면 플랫폼이 알아서 끔 (아래 destroy()가 어떤 이유로 안 불려도 과금이 계속되지 않게). 10 * 60 * 1000 = 밀리초.
C로 치면 alarm()으로 거는 워치독 타이머.
37 try {
try { … } finally { … } — 중간에 오류가 나도 65줄 destroy()는 반드시 실행.
C로 치면 goto cleanup; 패턴을 언어가 보장해 주는 형태입니다. 중간에 예외(longjmp)가 나도 finally는 실행됩니다.
38 // ② 컨테이너로 스트리밍 (DO 메모리에 통째로 올리지 않음)
39 await this.files.mkdir(JOB_DIR, { recursive: true })
② 작업 폴더 만들기 (recursive: 상위 폴더도 함께).
40 await this.files.writeFile(`${JOB_DIR}/call.input`, call.body)
41 await this.files.writeFile(`${JOB_DIR}/response.input`, resp.body)
R2 스트림(call.body)을 그대로 컨테이너 파일로 씀. 파일 전체를 DO 메모리에 올리지 않고 흘려보냅니다(스트리밍).
C로 치면 파일 전체를 malloc해서 담지 않고, splice()처럼 스트림을 컨테이너 파일로 흘려보냅니다.
42
43 // ③ ffmpeg 실행 — 셸 없이 배열로, timeout으로 30분 상한
44 const proc = await c.exec([
45 'timeout', '--kill-after=5', '1800',
46 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-y',
47 '-i', '/opt/assets/intro.mp3', '-i', 'call.input',
48 '-i', '/opt/assets/interstitial.mp3', '-i', 'response.input',
49 '-i', '/opt/assets/outro.mp3',
50 '-filter_complex', FILTER, '-map', '[out]', 'episode.mp3',
51 ], { cwd: JOB_DIR })
③ 명령 실행. 배열 = 프로그램 이름과 인자. 셸을 거치지 않으므로 따옴표·공백 걱정이 없습니다.
· timeout --kill-after=5 1800: 1800초(30분) 넘으면 종료, 5초 뒤에도 안 끝나면 강제 종료
· -i 5개 = 입력 [0]~[4], -filter_complex = 위 FILTER, -map [out] = 최종 결과만 저장
· { cwd: JOB_DIR } = 이 폴더에서 실행 → call.input, episode.mp3처럼 짧은 경로 사용
C로 치면 chdir(JOB_DIR); execvp("timeout", argv); 그대로입니다. system("…")이 아니라서 셸 인용·주입 문제가 없습니다.
52 const out = await proc.output()
명령이 끝날 때까지 기다려 종료 코드·표준 출력·표준 오류를 한꺼번에 받음.
C로 치면 waitpid() + stdout/stderr 파이프를 끝까지 읽어 모으기.
53 if (out.exitCode !== 0) { // 124/137 = 시간 초과
54 throw new Error(`ffmpeg exit ${out.exitCode}: ${new TextDecoder().decode(out.stderr)}`)
55 }
종료 코드 0이 아니면 실패. 오류 메시지(바이트)를 TextDecoder로 문자열로 바꿔 함께 던짐. timeout으로 끊기면 124(SIGTERM) 또는 137(SIGKILL).
C로 치면 WEXITSTATUS. 137 = 128 + 9(SIGKILL). TextDecoder는 UTF-8 바이트 → 문자열 변환.
56
57 // ④ 결과를 R2로 스트리밍. R2.put은 길이를 알아야 하므로 stat → FixedLengthStream
58 const { size } = await this.files.stat(`${JOB_DIR}/episode.mp3`) // size는 bigint
④ 결과 파일 크기 확인. const { size } = 돌려받은 객체에서 size만 꺼내기. size는 bigint(아주 큰 정수용 타입).
C로 치면 bigint ≈ uint64_t. JS의 일반 숫자(number)는 double 하나뿐이라 253을 넘는 정수는 정확하지 않습니다.
59 const file = await this.files.readFile(`${JOB_DIR}/episode.mp3`) // Response
결과 파일을 Response 형태로 읽음 → file.body가 스트림.
60 const key = `drafts/${job.draftId}/episode.mp3`
61 await this.env.AUDIO.put(key, file.body!.pipeThrough(new FixedLengthStream(size)),
62 { httpMetadata: { contentType: 'audio/mpeg' } })
R2에 스트리밍 업로드. R2는 스트림을 받을 때 전체 길이를 알아야 하므로 FixedLengthStream(size)(Workers 내장)을 거쳐 "길이가 정해진 스트림"으로 만듭니다. file.body!의 ! = "null 아님을 내가 보장". contentType을 붙여 브라우저가 mp3로 인식하게.
C로 치면 HTTP로 치면 Content-Length를 먼저 알려 주고 write() 루프를 도는 것. body! ≈ assert(body != NULL)인데, 실행 시 검사는 하지 않습니다.
63 return { key, size: Number(size) }
결과를 돌려줌. Number(size)로 바꾸는 이유: index.ts가 이 값을 JSON.stringify하는데, bigint는 JSON으로 바꿀 수 없어 오류가 나기 때문.
C로 치면 (double)size 캐스트.
64 } finally {
65 if (c.running) await c.destroy() // 일회용: 끝나면 무조건 폐기 → 과금 종료
무조건 폐기. 성공이든 실패든 컨테이너를 없애 과금 종료. 다음 작업은 새 DO·새 컨테이너에서 깨끗하게 시작.
C로 치면 kill() + 자원 정리. 컨테이너 자체를 없앱니다.
66 }
67 }
68}
Lab Baudio-sandbox/src/index.tsWorker (Cloudflare)
1import { AudioSandbox, type Job } from './audio-sandbox'
2export { AudioSandbox }
DO 클래스와 Job 타입을 가져오고, 클래스를 다시 export. 런타임은 main 파일(index.ts)에서 export된 클래스만 DO로 찾을 수 있습니다. type Job의 type은 "타입만 가져옴" 표시.
C로 치면 re-export = 심볼을 export 테이블에 올리기. 런타임이 dlsym()처럼 이 이름으로 클래스를 찾습니다.
3
4// 진행 상황을 R2의 status/<draftId>.json에 기록 (Lab A의 콜백 저장과 같은 역할)
5async function setStatus(env: Env, draftId: string, event: Record<string, unknown>) {
6 await env.AUDIO.put(`status/${draftId}.json`, JSON.stringify({ draftId, ...event, at: new Date().toISOString() }),
7 { httpMetadata: { contentType: 'application/json' } })
8 console.log(`status ${draftId}: ${event.type}`)
9}
상태 기록 도우미. R2 status/<draftId>.json에 이벤트 + 시각을 저장하고 로그 출력. Record<string, unknown> = "아무 키·아무 값의 객체".
C로 치면 Record<string, unknown> ≈ char* 키의 해시맵. ...event는 그 내용을 얕게 복사해 넣기.
10
11export default {
12 async fetch(req: Request, env: Env) {
13 const url = new URL(req.url)
export default = 진입점. HTTP 요청마다 fetch(req, env)가 불립니다. env에는 wrangler.jsonc의 바인딩과 Secret이 들어 있음.
C로 치면 리눅스 드라이버의 struct file_operations { .read = …, .write = … }와 같은 구조. 함수 포인터 묶음을 런타임에 등록하면, 이벤트(HTTP 요청·큐 메시지)마다 런타임이 해당 함수를 부릅니다.
14
15 // 상태 조회 (토큰 불필요)
16 if (req.method === 'GET' && url.pathname.startsWith('/status/')) {
17 const obj = await env.AUDIO.get(`status/${url.pathname.slice(8)}.json`)
18 return obj ? new Response(obj.body, { headers: { 'content-type': 'application/json' } })
19 : Response.json({ status: 'unknown' }, { status: 404 })
20 }
GET /status/<id>: 토큰 없이 상태 조회. slice(8) = 앞의 /status/(8글자)를 잘라낸 나머지.
C로 치면 slice(8) ≈ path + 8 (새 문자열을 만든다는 점만 다름).
21
22 // 나머지는 토큰 필수 (배포 후 아무나 R2에 쓰지 못하게)
23 if (req.headers.get('authorization') !== `Bearer ${env.JOB_TOKEN}`) return new Response('unauthorized', { status: 401 })
그 밖의 모든 요청은 토큰 필수. 배포 후 아무나 R2에 쓰거나 작업을 넣지 못하게.
C로 치면 JS의 문자열 !==는 포인터 비교가 아니라 strcmp()처럼 내용 비교입니다.
24
25 // 테스트용 업로드: PUT /upload/<R2 키>
26 if (req.method === 'PUT' && url.pathname.startsWith('/upload/')) {
27 await env.AUDIO.put(url.pathname.slice(8), req.body) // Content-Length 필요 (curl -T는 자동)
28 return new Response('stored')
29 }
PUT /upload/<키> (테스트용): 요청 본문을 그대로 R2에 저장. curl -T 파일이 길이(Content-Length)를 붙여 보내므로 스트림 그대로 저장 가능.
30
31 // 작업 접수: 키만 큐에 넣고 바로 202
32 if (req.method === 'POST' && url.pathname === '/jobs') {
33 const job = await req.json<Job>()
POST /jobs: 본문을 Job으로 읽음. req.json<Job>()의 <Job>은 결과 타입 지정.
34 await env.JOBS.send(job)
큐에 넣기. 오디오가 아니라 R2 키 2개와 draftId만 들어갑니다.
35 await setStatus(env, job.draftId, { type: 'queued' })
36 return Response.json({ status: 'queued', draftId: job.draftId }, { status: 202 })
"queued" 기록 후 즉시 202. 이 요청은 여기서 끝 (로그상 약 9ms).
37 }
38 return new Response('not found', { status: 404 })
39 },
일치하는 경로가 없으면 404.
40
41 async queue(batch: MessageBatch<Job>, env: Env) {
Queue consumer. 런타임이 메시지를 모아(최대 3개 / 2초) 이 함수를 부릅니다.
C로 치면 콜백. 이벤트 루프(epoll 루프)가 메시지 이벤트를 받으면 이 함수를 부릅니다.
42 for (const m of batch.messages) {
43 const job = m.body
묶음 안 메시지를 하나씩. m.body = send()로 넣었던 Job 객체 그대로.
44 try {
45 await setStatus(env, job.draftId, { type: 'started', attempt: m.attempts })
"started" 기록 (몇 번째 시도인지 포함).
46 // 작업마다 새 이름 → 새 DO → 새 컨테이너 (Kent: call-kent-{draftId}-{랜덤})
47 const sb = env.AUDIO_SANDBOX.getByName(`job-${job.draftId}-${crypto.randomUUID().slice(0, 8)}`)
작업마다 새 이름 → getByName이 그 이름의 DO 손잡이(stub)를 줌 → 처음 보는 이름이므로 새 DO, 새 컨테이너. (Lab A는 늘 'singleton' 1대)
C로 치면 이름 → 해시 → 핸들. shm_open("/job-…")처럼 이름이 같으면 같은 객체, 다르면 새 객체.
48 const result = await sb.stitch(job) // RPC 호출: 끝날 때까지 기다림
RPC 호출. DO의 stitch()가 끝날 때까지 기다림. 그 안에서 던진 오류도 여기로 그대로 넘어와 catch로 갑니다.
C로 치면 블로킹 호출처럼 보이지만 스레드를 막지 않습니다. 이 함수만 여기서 멈추고, 같은 스레드는 다른 요청을 처리하다가 결과가 오면 다음 줄부터 이어갑니다.
49 await setStatus(env, job.draftId, { type: 'completed', ...result })
50 m.ack()
성공: 결과(키·크기)를 펼쳐 "completed" 기록 → m.ack()로 완료 표시.
51 } catch (e) {
52 console.error(`job ${job.draftId} failed (attempt ${m.attempts})`, e)
53 await setStatus(env, job.draftId, { type: 'failed', attempt: m.attempts, error: String(e) }).catch(() => {})
54 m.retry({ delaySeconds: 10 }) // max_retries 넘으면 DLQ
55 }
실패: 로그 → "failed" 기록(기록마저 실패해도 무시) → m.retry({ delaySeconds: 10 }). 10초 뒤 같은 메시지로 queue()가 다시 불리고, 4번째도 실패하면 DLQ로.
C로 치면 catch = setjmp 착지점. 다른 DO에서 던진 오류도 RPC를 건너 여기로 옵니다.
56 }
57 },
58} satisfies ExportedHandler<Env, Job>
satisfies ExportedHandler<Env, Job> = "이 객체가 Worker 진입점 규칙(fetch·queue의 인자 타입 등)에 맞는지 검사해 줘". 실행에는 영향 없음.
C로 치면 _Static_assert처럼 컴파일 시간에만 검사하고 코드를 만들지 않습니다.
Lab Baudio-sandbox/package.json내 Mac (npm)
1{
2 "name": "audio-sandbox",
3 "version": "0.0.0",
4 "private": true,
이 프로젝트의 이름·버전. private: true = npm에 실수로 공개되지 않게.
5 "scripts": {
6 "deploy": "wrangler deploy",
7 "dev": "wrangler dev",
8 "start": "wrangler dev",
9 "test": "vitest",
10 "cf-typegen": "wrangler types"
11 },
scripts = npm run 이름으로 부르는 단축 명령. npm run dev = wrangler dev, npm run cf-typegen = wrangler types.
C로 치면 npm run dev ≈ make dev. scripts는 Makefile 타깃 모음.
12 "devDependencies": {
13 "@cloudflare/vitest-plugin": "^1.4.3",
14 "@types/node": "^26.6.6",
15 "typescript": "^5.5.2",
16 "vitest": "~5.0.3",
17 "wrangler": "^4"
18 },
devDependencies = 개발할 때만 필요한 도구(배포 결과물에는 안 들어감): 테스트(vitest), 타입(@types/node), TypeScript, wrangler. ^4 = 4.x 중 최신 허용.
C로 치면 빌드 도구(컴파일러·테스트 프레임워크)를 프로젝트 안에 버전 고정해 두는 것. node_modules/에 설치됩니다.
19 "dependencies": {
20 "@cloudflare/sandbox": "^1.0.3"
21 }
22}
dependencies = 실제 코드가 쓰는 패키지. wrangler가 번들할 때 함께 묶습니다. @cloudflare/sandbox의 Files가 여기서 옴.
C로 치면 정적 링크할 라이브러리. 배포 때 번들(=정적 링크)에 포함됩니다.
Lab Baudio-sandbox/tsconfig.json (주석 줄 생략)내 Mac (tsc 검사)
1{
2 "compilerOptions": {
tsconfig.json = TypeScript 검사 규칙. npx tsc --noEmit이 이 파일을 읽습니다. (실제 변환·번들은 wrangler가 따로 하므로 여기 설정은 주로 "검사"용)
C로 치면 tsconfig.json ≈ CFLAGS 모음.
3 "target": "es2024",
4 "lib": ["es2024"],
target/lib: 어떤 JavaScript 버전 기능을 쓸 수 있다고 볼지 (ES2024).
5 "jsx": "react-jsx",
JSX(React 문법) 처리 방식. 이 프로젝트에서는 안 씀(템플릿 기본값).
6 "module": "es2022",
7 "moduleResolution": "Bundler",
module: import/export 문법 사용. moduleResolution: "Bundler": 번들러(wrangler/esbuild)가 찾는 방식으로 import 경로 해석.
8 "resolveJsonModule": true,
9 "allowJs": true,
10 "checkJs": false,
JSON import 허용, JS 파일도 포함(검사는 안 함).
11 "noEmit": true,
noEmit: 검사만 하고 .js 파일을 만들지 않음.
C로 치면 gcc -fsyntax-only와 같습니다.
12 "isolatedModules": true,
13 "allowSyntheticDefaultImports": true,
14 "forceConsistentCasingInFileNames": true,
isolatedModules: 파일 하나씩 변환해도 안전한 코드만 허용(그래서 타입만 가져올 때 type 표시가 필요). 나머지는 import 호환·대소문자 검사.
15 "strict": true,
strict: true = 엄격 모드. null일 수 있는 값을 그냥 쓰면 오류 → 코드의 !, ??, if (!call) 같은 처리가 필요해진 이유.
C로 치면 -Wall -Wextra -Werror에 가깝습니다. 특히 NULL 가능성을 엄격히 따집니다.
16 "skipLibCheck": true
17 },
라이브러리 타입 파일(.d.ts) 검사는 생략(속도).
18 "exclude": ["test"],
19 "include": ["worker-configuration.d.ts", "src/**/*.ts"]
20}
include: 검사 대상 = 자동 생성 타입 파일 + src의 모든 .ts. test 폴더는 제외.
Lab Baudio-sandbox/worker-configuration.d.ts (앞 16줄)내 Mac (편집기·tsc)
1/* eslint-disable */
2// Generated by Wrangler by running `wrangler types` (hash: 2436f52b99101b05a8170503f8158c06)
3// Runtime types generated with workerd@1.20261104.1 2026-11-04 nodejs_compat
자동 생성 파일 — 직접 고치지 않습니다. npx wrangler types가 wrangler.jsonc와 .dev.vars를 읽어 만듭니다. wrangler.jsonc를 바꾸면 다시 실행하세요.
C로 치면 자동 생성된 헤더 파일(.h). 선언(extern)만 있고 구현은 런타임에 있습니다.
4interface __BaseEnv_Env {
5 AUDIO: R2Bucket;
6 JOBS: Queue;
7 JOB_TOKEN: string;
8 AUDIO_SANDBOX: DurableObjectNamespace<import("./src/index").AudioSandbox>;
9}
여기가 env의 정체. 바인딩 이름 → 타입:
· AUDIO: R2Bucket (r2_buckets)
· JOBS: Queue (queues.producers)
· JOB_TOKEN: string (.dev.vars의 Secret)
· AUDIO_SANDBOX: DurableObjectNamespace<AudioSandbox> (durable_objects)
그래서 코드에서 env.을 치면 자동 완성이 되고, stub.stitch의 인자·반환 타입까지 검사됩니다.
C로 치면 struct Env { R2Bucket *AUDIO; Queue *JOBS; const char *JOB_TOKEN; … }; 선언과 같습니다.
10declare namespace Cloudflare {
11 interface GlobalProps {
12 mainModule: typeof import("./src/index");
13 durableNamespaces: "AudioSandbox";
14 }
15 interface Env extends __BaseEnv_Env {}
16}
Cloudflare 내부용 전역 타입 연결. main 모듈과 DO 이름을 등록하고, 전역 Env를 위 목록과 같게 만듭니다. 이 아래로 1만 6천 줄은 Workers 런타임 API(Request, R2Bucket, FixedLengthStream …) 타입입니다.
Lab Baudio-sandbox/.dev.vars내 Mac (wrangler dev)
1JOB_TOKEN=<openssl rand -hex 32 로 만든 값>
.dev.vars = 로컬 wrangler dev용 Secret. 이름=값 한 줄에 하나. 코드에서 env.JOB_TOKEN.
.env.production도 같은 형식으로 운영용 값을 두고 wrangler deploy --secrets-file .env.production으로 올립니다. 두 파일 모두 .gitignore에 있어 Git에 올라가지 않습니다.
Lab A는 같은 방식으로 R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, JOB_TOKEN, CALLBACK_SECRET 4개를 둡니다.
C로 치면 execve()의 envp로 들어가는 값과 같은 역할. 단 코드에서는 getenv()가 아니라 env.JOB_TOKEN으로 읽습니다.

5. Lab A 파일 해설 Container 클래스

프로젝트 폴더 ~/Labs/container/kentcontainer. Lab B와 달리 컨테이너 안에 HTTP 서버(server.mjs)가 있고, Worker의 DO 클래스(JobContainer)가 index.ts 안에 같이 있습니다. package.json · tsconfig.json · worker-configuration.d.ts의 역할은 Lab B와 같습니다.

Lab Akentcontainer/wrangler.jsonc (보기 쉽게 줄 정리)배포 설정 (wrangler가 읽음)
1{
JSONC = 주석(//)을 넣을 수 있는 JSON. 중괄호 { } 안에 "이름": 값 쌍을 쉼표로 나열합니다. 이 파일은 wrangler가 배포할 때 읽는 설계도이고, 코드 안에서는 env라는 모습으로 나타납니다.
2 "name": "my-job-container",
Worker 이름. 배포 주소 my-job-container.<서브도메인>.workers.dev의 앞부분이 됩니다.
3 "account_id": "<ACCOUNT_ID>",
어느 Cloudflare 계정에 배포할지. 계정이 여러 개일 때 매번 묻지 않게 고정합니다.
4 "main": "src/index.ts",
Worker의 시작 파일. wrangler가 이 파일부터 import를 따라가 하나로 묶어(번들) 올립니다.
5 "compatibility_date": "2026-09-28",
6 "compatibility_flags": ["nodejs_compat"],
compatibility_date: 런타임 동작 기준 날짜(이 날짜의 동작을 계속 보장). nodejs_compat: node: 모듈 일부를 Worker에서 쓸 수 있게 하는 스위치.
7 "observability": { "enabled": true },
대시보드에서 로그·트레이스를 보이게 합니다. wrangler tail과 별개로 저장됩니다.
8 "vars": {
9 "R2_ENDPOINT": "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
10 "R2_BUCKET": "my-job-bucket"
11 },
vars = 평문 환경 변수. 코드에서 env.R2_ENDPOINT, env.R2_BUCKET으로 읽습니다. 비밀이 아닌 값만 넣습니다. (R2 키·토큰 같은 Secret은 여기 넣지 않고 .dev.vars / --secrets-file로)
C로 치면 vars ≈ envp에 들어가는 평문 값.
12 "containers": [
13 {
14 "class_name": "JobContainer",
15 "image": "./container/Dockerfile",
16 "instance_type": "basic",
17 "max_instances": 2
18 }
19 ],
containers = 이 Worker가 쓸 컨테이너 목록.
· class_name: 이 컨테이너를 켜고 끌 Durable Object 클래스 이름 (index.ts의 export class JobContainer와 같아야 함)
· image: 빌드할 Dockerfile 경로
· instance_type: 크기 (lite / basic / standard-1 …)
· max_instances: 동시에 켜질 수 있는 최대 대수
20 "durable_objects": {
21 "bindings": [
22 { "name": "JOB_CONTAINER", "class_name": "JobContainer" }
23 ]
24 },
durable_objects.bindings = 코드에서 DO를 부를 "손잡이". env.JOB_CONTAINER라는 이름으로 JobContainer 클래스의 인스턴스를 얻을 수 있게 됩니다.
C로 치면 바인딩 = 미리 열어 넘겨주는 fd. env.JOB_CONTAINER로 DO 핸들을 얻습니다.
25 "migrations": [
26 { "tag": "v1", "new_sqlite_classes": ["JobContainer"] }
27 ],
migrations = DO 클래스의 등록 이력. v1에서 JobContainer를 SQLite 저장소를 쓰는 새 클래스로 등록. 클래스를 추가·이름 변경할 때마다 태그를 하나씩 늘려 기록합니다. (Lab B는 새 방식 exports를 사용)
28 "queues": {
29 "producers": [{ "binding": "JOBS", "queue": "my-jobs" }],
producers = "보내는 쪽" 설정. 코드에서 env.JOBS.send(...)를 부르면 my-jobs 큐에 메시지가 들어갑니다.
30 "consumers": [{
31 "queue": "my-jobs",
32 "max_batch_size": 3,
33 "max_batch_timeout": 2,
34 "max_retries": 3,
35 "dead_letter_queue": "my-jobs-dlq"
36 }]
consumers = "받는 쪽" 설정. 이 블록이 있어야 Cloudflare가 이 Worker의 queue() 함수를 대신 불러 줍니다.
· max_batch_size: 3 — 한 번에 최대 3개씩
· max_batch_timeout: 2 — 3개가 안 모여도 2초 지나면 보냄
· max_retries: 3 — 실패 시 3번 더 (총 4번)
· dead_letter_queue — 그래도 실패하면 옮겨 둘 큐(DLQ)
C로 치면 큐 메시지 → queue 콜백 등록표. signal() 등록을 설정 파일로 하는 셈.
37 },
queues 블록 끝.
38 "r2_buckets": [
39 { "bucket_name": "my-job-bucket", "binding": "JOB_BUCKET" }
40 ]
R2 버킷 바인딩. 코드에서 env.JOB_BUCKET.get/put으로 키 없이 바로 읽고 씁니다. (Worker 쪽 전용. 컨테이너 안에서는 이 바인딩을 못 쓰므로 Lab A는 S3 키를 따로 넘김)
41}
파일 끝.
Lab Akentcontainer/container/Dockerfile컨테이너 이미지
1FROM node:24-bookworm-slim
FROM = 출발점 이미지. Node.js 24가 들어 있는 Debian(bookworm) 경량판. 여기에 필요한 것만 얹습니다.
2RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates \
3 && rm -rf /var/lib/apt/lists/*
RUN = 빌드 중에 명령 실행. apt-get으로 ffmpeg와 HTTPS 인증서 묶음 설치 → 설치 목록 캐시 삭제(이미지 크기 줄이기). \는 "다음 줄에 이어짐", &&는 "앞이 성공하면 다음 실행".
4WORKDIR /app
WORKDIR = 이후 명령과 실행의 기준 폴더를 /app으로.
5COPY package.json package-lock.json* ./
6RUN npm install --omit=dev --no-audit --no-fund
package.json을 먼저 복사해 의존성만 설치 (--omit=dev: 개발용 제외). 코드만 바뀌면 이 단계는 캐시를 재사용해 빌드가 빨라집니다.
C로 치면 make의 의존성 기반 재빌드와 같은 원리. 앞 레이어의 입력 파일이 그대로면 캐시를 재사용합니다.
7COPY . .
나머지 파일(server.mjs 등)을 복사.
8EXPOSE 8080
EXPOSE = "이 컨테이너는 8080을 씁니다"라는 표시. 로컬 dev가 포트를 찾을 때 사용합니다. index.ts의 defaultPort = 8080과 맞춥니다.
9USER node
USER node = root가 아닌 일반 사용자로 실행 (보안 기본기).
C로 치면 setuid()로 root 권한을 버리고 실행하는 것과 같습니다.
10CMD ["node", "server.mjs"]
CMD = 컨테이너가 켜지면 실행할 명령. node server.mjs → HTTP 서버가 8080에서 대기.
Lab Akentcontainer/container/package.json컨테이너 안 Node
1{
2 "name": "worker-container",
3 "type": "module",
4 "private": true,
5 "dependencies": { "@aws-sdk/client-s3": "^3.1164.0" }
6}
컨테이너 안 Node 프로그램(server.mjs)용 package.json. Worker 쪽과 별개입니다.
· "type": "module" — import 문법 사용
· @aws-sdk/client-s3 — R2를 S3 방식으로 다루는 라이브러리 (컨테이너에는 R2 바인딩이 없어서 필요)
Lab Akentcontainer/container/server.mjs컨테이너 안 (Node HTTP 서버)
1import { createServer } from 'node:http'
2import { execFile } from 'node:child_process'
3import { promisify } from 'node:util'
4import { createHmac } from 'node:crypto'
5import { writeFile, readFile, rm } from 'node:fs/promises'
Node 내장 모듈 가져오기. node:http(서버), child_process(ffmpeg 실행), util.promisify(콜백 함수를 await 가능하게), crypto.createHmac(서명), fs/promises(파일 읽기·쓰기·삭제).
C로 치면 node: 모듈 = libc 헤더 자리. http는 소켓 서버, child_process는 fork/exec, fs는 파일 I/O.
6import { S3Client, GetObjectCommand, PutObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
S3 SDK에서 필요한 것만 골라 가져옴: 클라이언트 + 명령 3종(가져오기·올리기·목록).
7
8const run = promisify(execFile)
execFile은 원래 "끝나면 콜백 호출" 방식. promisify로 감싸 await run(...)처럼 쓸 수 있게 만듭니다.
C로 치면 콜백을 받는 비동기 함수를 await할 수 있는 형태로 감싸는 어댑터.
9const s3 = new S3Client({
10 region: 'auto',
11 endpoint: process.env.R2_ENDPOINT,
12 forcePathStyle: true,
13 requestChecksumCalculation: 'WHEN_REQUIRED',
14 responseChecksumValidation: 'WHEN_REQUIRED',
15 credentials: {
16 accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '',
17 secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? '',
18 },
19})
R2에 접속할 S3 클라이언트를 한 번만 만들어 둡니다. 값은 모두 process.env = 컨테이너 환경 변수 (Worker가 startAndWaitForPorts의 envVars로 넣어 줌).
· region: "auto", endpoint: R2 전용 주소
· forcePathStyle: 주소를 /버킷/키 형태로
· ...Checksum...: WHEN_REQUIRED: 최신 SDK의 기본 체크섬 헤더를 꺼서 R2와 호환
· ?? '': 값이 없으면 빈 문자열
C로 치면 프로그램 시작 시 한 번 초기화하는 전역 static 객체.
20const Bucket = process.env.R2_BUCKET
버킷 이름. { Bucket }처럼 같은 이름의 변수를 그대로 객체에 넣을 때 씁니다.
21const active = new Set() // 지금 처리 중인 작업 id (중복 접수 방지, Lab 4에서 사용)
22let draining = false // SIGTERM을 받으면 true → 새 작업 거절
Set = 중복 없는 모음. 지금 처리 중인 작업 id를 담아 같은 작업이 두 번 돌지 않게 하고, 개수(active.size)는 Worker의 onActivityExpired가 물어봅니다. draining은 종료 신호를 받았는지 표시.
C로 치면 draining은 시그널 핸들러가 세우는 volatile sig_atomic_t 플래그와 같은 용도.
23
24const readBody = req => new Promise((ok, ng) => { let b = ''; req.on('data', c => b += c); req.on('end', () => ok(b)); req.on('error', ng) })
요청 본문(body)을 끝까지 모아 문자열로 돌려주는 도우미. Node의 요청은 조각(chunk)으로 들어오므로 data 이벤트마다 이어 붙이고, end에서 완성본을 돌려줍니다. new Promise((ok, ng) => …)는 "나중에 ok(값) 또는 ng(오류)로 끝나는 약속"을 직접 만드는 문법.
C로 치면 recv() 루프로 버퍼에 이어 붙이다가 EOF에서 반환. 단 블로킹 없이 이벤트(data, end)로 옵니다.
25const json = (res, code, obj) => { res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(obj)) }
JSON 응답을 한 줄로 보내는 도우미: 상태 코드 + content-type 헤더 + 본문.
26
27// 서명 콜백: body 앞에 timestamp를 붙여 HMAC-SHA256 → 받는 쪽이 같은 계산으로 검증
28async function callback(job, event) {
29 const body = JSON.stringify({ id: job.id, ...event })
30 const ts = String(Date.now())
31 const sig = createHmac('sha256', process.env.CALLBACK_SECRET).update(`${ts}.${body}`).digest('hex')
32 const r = await fetch(job.callbackUrl, { method: 'POST', body,
33 headers: { 'content-type': 'application/json', 'x-timestamp': ts, 'x-signature': sig } })
34 console.log(`[job ${job.id}] callback ${event.type} → ${r.status}`)
35}
서명 콜백 보내기.
① 보낼 본문을 JSON 문자열로 (...event: event의 속성을 펼쳐 넣기)
② 현재 시각(ms)을 문자열로
③ "시각.본문"을 CALLBACK_SECRET으로 HMAC-SHA256 → 16진수 서명
④ fetch로 POST, 시각·서명을 헤더에 담음
받는 쪽(index.ts의 onCallback)이 같은 계산을 해서 맞는지 확인합니다. job.callbackUrl은 http://callback.internal/events — 실제 인터넷 주소가 아니라 Worker가 가로채는 가짜 호스트입니다.
C로 치면 OpenSSL HMAC(EVP_sha256(), key, …) 후 hex 인코딩.
36
37async function runJob(job) {
38 const inPath = `/tmp/${job.id}.in`, outPath = `/tmp/${job.id}.mp3`
실제 작업 함수. 입력·출력 임시 파일 경로를 작업 id로 만듭니다.
39 try {
40 await callback(job, { type: 'started' })
41 // (Lab 4 실험용) delaySec만큼 일부러 오래 걸리게 → sleepAfter보다 긴 작업 흉내
42 if (job.delaySec) await new Promise(r => setTimeout(r, job.delaySec * 1000))
"시작" 콜백 → (실험용) delaySec초 일부러 대기. new Promise(r => setTimeout(r, ms))는 "ms 뒤에 끝나는 약속" = sleep.
C로 치면 nanosleep()처럼 보이지만 스레드를 막지 않습니다. 그동안 다른 요청을 처리합니다.
43
44 // 1) R2에서 입력 받기
45 const obj = await s3.send(new GetObjectCommand({ Bucket, Key: job.inputKey }))
46 await writeFile(inPath, Buffer.from(await obj.Body.transformToByteArray()))
① R2에서 입력 파일 받기 → 바이트로 바꿔 /tmp에 저장.
47 // 2) FFmpeg 변환 (모노 64kbps MP3)
48 await run('ffmpeg', ['-y', '-loglevel', 'error', '-i', inPath, '-ac', '1', '-b:a', '64k', outPath])
② ffmpeg 실행 (셸 없이 인자 배열로). 모노·64kbps MP3로 변환. 끝날 때까지 await.
C로 치면 fork() + execvp("ffmpeg", argv) + waitpid().
49 // 3) 결과를 R2에 올리기
50 const outKey = `out/${job.id}.mp3`
51 const data = await readFile(outPath)
52 await s3.send(new PutObjectCommand({ Bucket, Key: outKey, Body: data, ContentType: 'audio/mpeg' }))
③ 결과를 읽어 R2 out/<id>.mp3로 업로드.
53 await callback(job, { type: 'completed', outputKey: outKey, bytes: data.length })
"완료" 콜백 (결과 키와 크기 포함).
54 } catch (e) {
55 console.error(`[job ${job.id}] failed`, e)
56 await callback(job, { type: 'failed', error: String(e) }).catch(() => {})
어느 단계든 실패하면 여기로. 로그를 남기고 "실패" 콜백. 콜백마저 실패해도 무시(.catch(() => {})).
57 } finally {
58 await rm(inPath, { force: true }); await rm(outPath, { force: true })
59 }
finally = 성공·실패와 상관없이 마지막에 항상 실행. 임시 파일 삭제 (force: 없어도 오류 안 냄).
C로 치면 unlink() 정리를 cleanup 레이블에서 하는 것과 같습니다.
60}
61
62createServer(async (req, res) => {
63 try {
HTTP 서버 본체. 요청이 올 때마다 이 함수가 (req, res)로 불립니다. 바깥 try는 예상 못 한 오류를 500으로 돌려주기 위함(87줄).
C로 치면 accept() 루프를 직접 쓰지 않습니다. 런타임이 epoll로 연결을 다중화하고, 요청마다 이 함수를 콜백으로 부릅니다.
64 if (req.url === '/health') return res.end('ok')
/health: 살아 있는지 확인용. 토큰 없이 OK.
65 if (req.url === '/version') {
66 return execFile('ffmpeg', ['-version'], (err, out) => res.end(err ? String(err) : out.split('\n')[0]))
67 }
/version: ffmpeg -version의 첫 줄을 돌려줌 (이미지에 ffmpeg가 있는지 확인).
68 if (req.url === '/internal/status') return json(res, 200, { activeJobs: active.size }) // Lab 4에서 사용
/internal/status: 처리 중인 작업 수. Worker의 onActivityExpired()가 컨테이너를 끌지 말지 판단할 때 부릅니다.
69 if (req.headers.authorization !== `Bearer ${process.env.JOB_TOKEN}`) return json(res, 401, { error: 'unauthorized' })
여기서부터는 토큰 필수. Authorization: Bearer <JOB_TOKEN>이 아니면 401.
70
71 if (req.url === '/r2test') {
72 const Key = `lab2/hello-${Date.now()}.txt`
73 await s3.send(new PutObjectCommand({ Bucket, Key, Body: 'hello from container' }))
74 const list = await s3.send(new ListObjectsV2Command({ Bucket, Prefix: 'lab2/' }))
75 return json(res, 200, { wrote: Key, keys: (list.Contents ?? []).map(o => o.Key) })
76 }
/r2test (Lab 2): R2 쓰기 → 목록 읽기로 키가 제대로 동작하는지 확인. ?? []: 결과가 없으면 빈 배열, .map(o => o.Key): 각 항목에서 키만 뽑기.
77 if (req.url === '/jobs' && req.method === 'POST') {
78 if (draining) return json(res, 503, { error: 'shutting down' }) // 큐는 503을 받으면 retry
POST /jobs: 작업 접수. 종료 중이면 503 → Worker의 queue()가 실패로 보고 재시도합니다.
79 const job = JSON.parse(await readBody(req)) // { id, inputKey, callbackUrl }
80 if (active.has(job.id)) return json(res, 202, { status: 'already-running' })
본문을 JSON으로 해석. 이미 처리 중인 id면 다시 시작하지 않고 202만 (큐가 같은 메시지를 두 번 줄 수 있으므로).
81 active.add(job.id)
82 runJob(job).finally(() => active.delete(job.id)) // 기다리지 않음 → 바로 202
83 return json(res, 202, { status: 'accepted', id: job.id })
핵심 3줄. id 등록 → runJob(job)을 await 없이 시작(백그라운드) → 끝나면 id 제거(.finally) → 곧바로 202 응답. 그래서 Worker는 ffmpeg를 기다리지 않습니다.
C로 치면 pthread_create() 후 detach처럼 보이지만 스레드가 아닙니다. 같은 이벤트 루프에 작업을 걸어 두고 바로 돌아오며, ffmpeg를 기다리는 동안에도 이 스레드는 다른 요청을 받습니다.
84 }
85 json(res, 404, { error: 'not found' })
86 } catch (e) { json(res, 500, { error: String(e) }) }
그 밖의 경로는 404. 처리 중 오류는 500.
87}).listen(8080, '0.0.0.0', () => console.log('listening 8080'))
0.0.0.0:8080에서 대기. 127.0.0.1로 열면 컨테이너 밖(Worker)에서 접속할 수 없습니다.
C로 치면 bind(fd, INADDR_ANY:8080) + listen().
88
89
90// 정상 종료: 플랫폼은 SIGTERM → 최대 15분 대기 → SIGKILL 순서로 끈다.
91// node가 PID 1이면 핸들러 없이는 SIGTERM이 무시돼 15분 동안 꺼지지 않는다 (= 과금).
플랫폼은 컨테이너를 끌 때 SIGTERM을 보내고, 최대 15분 기다린 뒤 SIGKILL로 강제 종료합니다.
92process.on('SIGTERM', async () => {
93 draining = true
94 console.log(`SIGTERM: ${active.size} job(s) running — finishing before exit`)
95 while (active.size > 0) await new Promise(r => setTimeout(r, 500))
96 console.log('all jobs done, exiting')
97 process.exit(0)
98})
SIGTERM 처리. 새 작업 거절(draining = true) → 처리 중 작업이 0이 될 때까지 0.5초마다 확인 → 정상 종료(exit(0)). 이 처리가 없으면 node(PID 1)는 SIGTERM을 무시해 15분 동안 켜져 있게 됩니다.
C로 치면 sigaction(SIGTERM, …). 리눅스 커널은 PID 1에 핸들러가 없는 시그널을 전달하지 않으므로, 핸들러를 직접 달아야 SIGTERM으로 끝낼 수 있습니다.
Lab Akentcontainer/src/index.tsWorker + Durable Object (Cloudflare)
1import { Container, getContainer } from '@cloudflare/containers'
@cloudflare/containers 패키지에서 Container 클래스(컨테이너를 관리하는 DO의 부모)와 getContainer(DO 손잡이 얻기) 가져오기.
2export { ContainerProxy } from '@cloudflare/containers' // outboundByHost(콜백 가로채기)에 필요
ContainerProxy를 다시 내보냄. 아래 outboundByHost(컨테이너가 보내는 요청 가로채기)가 동작하려면 Worker가 이 클래스를 export하고 있어야 합니다.
3
4// 컨테이너가 http://callback.internal/... 로 보내는 요청을 Worker가 가로채 처리 (공개 URL 불필요)
5async function onCallback(req: Request, env: Env): Promise<Response> {
콜백 수신 함수. 컨테이너가 http://callback.internal/...로 보낸 요청이 인터넷으로 나가지 않고 이 함수로 들어옵니다. (req: Request, env: Env): Promise<Response> = "Request와 Env를 받아, 나중에 Response를 돌려주는 함수"라는 타입 표기.
C로 치면 typedef Response (*handler_t)(Request, Env); 같은 함수 시그니처. 반환이 Promise라 비동기 함수입니다.
6 const body = await req.text()
7 const ts = req.headers.get('x-timestamp') ?? ''
8 const sig = req.headers.get('x-signature') ?? ''
본문을 문자열 그대로 읽기(서명은 바이트 단위라 JSON.parse 전에 원문 필요). 헤더가 없으면 ?? ''로 빈 문자열.
9 // 1) 5분 넘은 요청은 거부 (재전송 공격 방지)
10 if (!ts || Math.abs(Date.now() - Number(ts)) > 5 * 60_000) return new Response('stale', { status: 401 })
시각이 없거나 현재와 5분 이상 차이 나면 거부 → 예전 요청을 다시 보내는 공격(재전송) 방지. 5 * 60_000: _는 숫자 구분용(=60000).
11 // 2) 같은 비밀로 다시 계산해 비교
12 const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(env.CALLBACK_SECRET),
13 { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
Web Crypto API로 HMAC 키 만들기: CALLBACK_SECRET 문자열 → 바이트 → "검증 전용" 키.
C로 치면 OpenSSL EVP API에 해당하는 표준 Web Crypto.
14 const sigBytes = new Uint8Array((sig.match(/../g) ?? []).map(h => parseInt(h, 16)))
16진수 서명 문자열을 바이트 배열로. /../g는 "두 글자씩 전부" 잘라내는 정규식, parseInt(h, 16)은 16진수 → 숫자.
C로 치면 sscanf(sig + 2*i, "%2hhx", &bytes[i])를 정규식 한 줄로.
15 const ok = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(`${ts}.${body}`))
16 if (!ok) return new Response('bad signature', { status: 401 })
같은 "시각.본문"으로 서명이 맞는지 검증. 틀리면 401.
17 // 3) 검증된 이벤트만 상태로 저장
18 const event = JSON.parse(body) as { id: string; type: string }
19 console.log('callback verified:', event.type, event.id)
20 await env.JOB_BUCKET.put(`status/${event.id}.json`, JSON.stringify({ ...event, at: new Date().toISOString() }),
21 { httpMetadata: { contentType: 'application/json' } })
22 return Response.json({ ok: true })
검증된 이벤트만 R2 status/<id>.json에 저장 (...event에 시각 추가). as { id: string; type: string }은 "이 모양이라고 믿고 쓰겠다"는 TS 표기(실행 시 검사는 안 함).
23}
24
25export class JobContainer extends Container<Env> {
컨테이너 관리용 DO 클래스. Container<Env>를 상속하면 시작·종료·포트 대기·요청 전달을 부모가 다 해 줍니다. wrangler.jsonc의 class_name: "JobContainer"와 이름이 같아야 연결됩니다.
C로 치면 부모 struct를 품고 vtable 일부를 바꾸는 구조. <Env>는 C++ 템플릿 인자처럼 this.env의 타입을 지정합니다.
26 defaultPort = 8080
27 sleepAfter = '1m' // 1분 동안 요청이 없으면 onActivityExpired() 호출
defaultPort: 요청을 전달할 컨테이너 포트. sleepAfter: 이 시간 동안 들어오는 요청이 없으면 onActivityExpired()를 부름.
28 static outboundByHost = { 'callback.internal': onCallback }
static = 인스턴스가 아니라 클래스에 붙는 값. "컨테이너가 callback.internal 호스트로 보내는 요청은 onCallback이 처리"라는 규칙표.
C로 치면 호스트명 → 핸들러 함수 포인터의 전역 static 테이블.
29 override onStart() { console.log('container started') }
30 override onStop() { console.log('container stopped') }
31 override onError(e: unknown) { console.error('container error', e) }
생명주기 훅: 시작·종료·오류 때 부모 클래스가 자동으로 불러 줍니다. override = "부모의 같은 이름 메서드를 바꿔 쓴다"는 표시.
C로 치면 vtable의 콜백 슬롯을 내 함수로 바꿔 끼우기.
32
33 // sleepAfter 동안 요청이 없으면 호출됨. 기본 구현은 stop().
34 // 작업이 남아 있으면 연장하고, 없으면 종료 (반드시 stop 호출 — 안 하면 계속 켜져 과금)
35 override async onActivityExpired() {
sleepAfter가 다 됐을 때 불리는 함수. 기본 구현은 그냥 stop()인데, 백그라운드 작업이 남아 있을 수 있으므로 직접 판단합니다.
36 const r = await this.containerFetch('http://container/internal/status')
37 const { activeJobs } = await r.json() as { activeJobs: number }
containerFetch로 컨테이너의 /internal/status를 물어 처리 중 작업 수를 받음. 주소의 container는 아무 이름이나 됩니다(어차피 이 컨테이너로 감).
38 if (activeJobs > 0) {
39 console.log(`still busy (${activeJobs} job), extend`)
40 this.renewActivityTimeout()
41 return
42 }
작업이 남아 있으면 타이머를 다시 1분으로 돌리고(renewActivityTimeout) 끝. 1분 뒤 또 불립니다.
43 console.log('idle, stopping container')
44 await this.stop()
일이 없으면 stop() → 컨테이너에 SIGTERM → 과금 종료. 이 호출을 빼먹으면 계속 켜져 있게 됩니다.
45 }
46}
47
48type Job = { id: string; inputKey: string; delaySec?: number }
큐 메시지의 모양을 type으로 정의. delaySec?의 ?는 "없어도 됨".
49
50// 컨테이너를 켜고(이미 켜져 있으면 그대로) 비밀을 환경 변수로 넣어 줌
51async function startContainer(env: Env) {
52 const c = getContainer(env.JOB_CONTAINER, 'singleton')
getContainer(바인딩, 이름) = 이름에 해당하는 DO 손잡이(stub). 이름이 항상 'singleton'이므로 모든 요청이 같은 DO·같은 컨테이너 1대로 갑니다.
C로 치면 shm_open("singleton")처럼 같은 이름이면 언제나 같은 객체.
53 await c.startAndWaitForPorts({ startOptions: { envVars: {
54 R2_ENDPOINT: env.R2_ENDPOINT, R2_BUCKET: env.R2_BUCKET,
55 R2_ACCESS_KEY_ID: env.R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY: env.R2_SECRET_ACCESS_KEY,
56 JOB_TOKEN: env.JOB_TOKEN, CALLBACK_SECRET: env.CALLBACK_SECRET,
57 } } })
58 return c
꺼져 있으면 켜고 8080이 열릴 때까지 기다림(켜져 있으면 바로 통과). 이때 Worker의 Secret과 vars를 envVars로 넣어 줍니다 → 컨테이너 안 process.env.*.
C로 치면 Secret을 execve()의 envp로 넘기는 것과 같습니다. 그래서 컨테이너 안에서는 process.env.X(= getenv("X"))로 읽습니다.
59}
60
61export default {
62 async fetch(request: Request, env: Env) {
export default { … } = 이 Worker의 진입점 모음. 런타임이 HTTP 요청이 오면 fetch를, 큐 메시지가 오면 queue를 부릅니다.
C로 치면 struct file_operations처럼 함수 포인터 묶음을 런타임에 등록.
63 const url = new URL(request.url)
요청 주소를 URL 객체로 만들어 경로(pathname)를 쉽게 꺼냄.
64
65 // 상태 조회: 콜백이 저장한 마지막 이벤트
66 if (url.pathname.startsWith('/status/')) {
67 const id = url.pathname.slice('/status/'.length)
68 if (!/^[\w-]+$/.test(id)) return Response.json({ error: 'bad id' }, { status: 400 })
69 const obj = await env.JOB_BUCKET.get(`status/${id}.json`)
70 return obj ? new Response(obj.body, { headers: { 'content-type': 'application/json' } })
71 : Response.json({ status: 'unknown' }, { status: 404 })
72 }
GET /status/<id>: R2의 상태 파일을 그대로 돌려줌. id는 영문·숫자·_·-만 허용(정규식 /^[\w-]+$/) → 이상한 경로로 다른 키를 읽지 못하게. 조건 ? A : B는 "참이면 A, 아니면 B".
73
74 // 작업 접수 (Lab 5): 큐에 넣고 바로 202. 컨테이너는 아직 켜지 않음
75 if (url.pathname === '/enqueue' && request.method === 'POST') {
76 if (request.headers.get('authorization') !== `Bearer ${env.JOB_TOKEN}`) return Response.json({ error: 'unauthorized' }, { status: 401 })
POST /enqueue (Lab 5): 토큰 확인.
77 const { inputKey, delaySec } = await request.json() as { inputKey: string; delaySec?: number }
78 const job: Job = { id: crypto.randomUUID().slice(0, 8), inputKey, delaySec }
본문에서 inputKey, delaySec만 꺼내고(구조 분해), 8자리 작업 id를 만듦.
79 await env.JOBS.send(job)
큐에 넣기. 여기서 메시지는 my-jobs 큐로 가고, 이 요청은 더 기다리지 않습니다.
80 await env.JOB_BUCKET.put(`status/${job.id}.json`, JSON.stringify({ id: job.id, type: 'queued', at: new Date().toISOString() }))
81 return Response.json({ status: 'queued', id: job.id }, { status: 202 })
"queued" 상태를 기록하고 202 Accepted로 즉시 응답. 컨테이너는 아직 켜지 않습니다.
82 }
83
84 const c = await startContainer(env)
여기까지 안 걸린 경로(/jobs, /health 등)만 컨테이너를 켭니다. 위의 /status·/enqueue가 컨테이너를 깨우지 않도록 순서를 이렇게 둡니다.
85
86 // 작업 접수: id를 만들어 컨테이너에 넘기고 바로 202
87 if (url.pathname === '/jobs' && request.method === 'POST') {
88 const { inputKey, delaySec } = await request.json() as { inputKey: string; delaySec?: number }
89 const id = crypto.randomUUID().slice(0, 8)
90 const r = await c.fetch(new Request('http://container/jobs', { method: 'POST',
91 headers: { authorization: request.headers.get('authorization') ?? '', 'content-type': 'application/json' },
92 body: JSON.stringify({ id, inputKey, delaySec, callbackUrl: 'http://callback.internal/events' }) }))
93 return new Response(r.body, { status: r.status, headers: { 'content-type': 'application/json' } })
94 }
POST /jobs (Lab 3, 큐 없이 직접): id를 붙여 컨테이너에 그대로 전달하고, 컨테이너의 202 응답을 사용자에게 돌려줌. c.fetch는 DO를 거쳐 컨테이너 8080으로 갑니다.
95 return c.fetch(request)
96 },
그 밖의 요청은 컨테이너에 그대로 넘김 (/health, /version, /r2test).
97
98 // Queue consumer (Lab 5): 메시지를 꺼내 컨테이너에 넘김. 실패하면 재시도 → max_retries 넘으면 DLQ
99 async queue(batch: MessageBatch<Job>, env: Env) {
Queue consumer. 직접 부르는 곳이 없습니다. wrangler.jsonc의 consumers 설정을 보고 Cloudflare 런타임이 메시지 묶음(batch)과 함께 불러 줍니다. MessageBatch<Job> = "Job 모양 메시지들의 묶음".
C로 치면 런타임이 부르는 콜백. 우리 코드 어디에도 호출하는 줄이 없습니다.
100 const c = await startContainer(env)
묶음마다 컨테이너를 한 번 켜 둠 (이미 켜져 있으면 바로 통과).
101 for (const m of batch.messages) {
묶음 안의 메시지를 하나씩. for … of = 배열의 각 항목을 차례로.
102 try {
103 const r = await c.fetch(new Request('http://container/jobs', { method: 'POST',
104 headers: { authorization: `Bearer ${env.JOB_TOKEN}`, 'content-type': 'application/json' },
105 body: JSON.stringify({ ...m.body, attempt: m.attempts, callbackUrl: 'http://callback.internal/events' }) }))
컨테이너 /jobs로 POST. 본문 = 메시지 내용(...m.body) + 시도 횟수 + 콜백 주소.
106 if (r.status !== 202) throw new Error(`container returned ${r.status}`)
202가 아니면(예: 종료 중 503) 일부러 오류를 던져 catch로 보냄.
107 console.log(`queue: job ${m.body.id} handed to container (attempt ${m.attempts})`)
108 m.ack()
m.ack() = "이 메시지는 처리 완료, 다시 주지 마". (실제 ffmpeg는 컨테이너에서 계속 돌고 있음)
C로 치면 메시지 큐에서 이 메시지를 확정 삭제.
109 } catch (e) {
110 console.error(`queue: job ${m.body.id} failed, retry`, e)
111 m.retry({ delaySeconds: 10 })
112 }
실패하면 m.retry({ delaySeconds: 10 }) = "10초 뒤 다시 줘". 시도 횟수(m.attempts)가 1씩 늘고, max_retries를 넘으면 DLQ로 이동.
113 }
114 },
115}

6. 용어 정리

용어뜻
바인딩 (binding)wrangler.jsonc에 적어 두면 코드의 env.이름으로 나타나는 연결. 키나 주소 없이 R2·Queue·DO를 바로 씁니다.
env바인딩 + vars + Secret을 담은 객체. 런타임이 fetch·queue에 넘겨주고, DO에서는 this.env.
Durable Object (DO)이름 하나당 전 세계에 딱 하나 있는 작은 서버 객체. 컨테이너를 켜고 끄는 관리인 역할.
stubDO를 부르기 위한 손잡이. getByName() / getContainer()가 돌려줌. 메서드를 부르면 실제로는 DO에서 실행.
RPC다른 곳에 있는 객체의 메서드를 내 함수처럼 부르는 방식 (sb.stitch(job)).
Promise · await"나중에 끝나는 일"과 그것을 기다리는 문법.
스트림 (ReadableStream)파일을 통째로 메모리에 올리지 않고 조각조각 흘려보내는 통로. obj.body, file.body.
FixedLengthStreamWorkers 내장. 전체 길이를 미리 알려 주는 스트림. R2에 스트림을 올릴 때 필요.
bigint아주 큰 정수용 숫자 타입 (123n). JSON으로 바꿀 수 없어 Number()로 변환.
sandbox-shim컨테이너 안에서 Files의 파일 작업을 대신 수행하는 작은 프로그램.
HMAC 서명공유 Secret으로 만든 "변조 방지 도장". 받는 쪽이 같은 계산을 해 맞는지 확인.
202 Accepted"접수했음, 결과는 나중에" 응답. 200(끝났음)과 구분.
DLQDead Letter Queue. 끝내 실패한 메시지를 보관하는 큐. 나중에 원인 확인·재처리용.
SIGTERM / SIGKILL"정리하고 끝내라" 신호 / "즉시 강제 종료" 신호.