Edge Runtime Isolates
ใจความสำคัญในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ใจความสำคัญในหนึ่งประโยค”supabase/edge-runtime คือโปรเซส Rust ที่ฝัง Deno runtime สองแบบต่างกัน — main worker ตัวเดียวที่ไม่ถูกจำกัดเลย คอยรับทุก request ที่เข้ามา และ user worker (isolate) แบบ sandbox หนึ่งตัวต่อ function สร้างด้วย allow_all: false และรายการ permission ที่แคบและชัดเจน — “cold start” ที่ What Are Edge Functions วัดไปแล้วคือต้นทุนครั้งเดียวจริง ๆ ของการสร้างและตั้งค่า permission ของ isolate ตัวนั้นเป๊ะ
พิสูจน์: permission model จริง จาก Rust source ตรง ๆ
หัวข้อที่มีชื่อว่า “พิสูจน์: permission model จริง จาก Rust source ตรง ๆ”ไม่ใช่การถอดความจากเอกสาร — crates/base/src/runtime/permissions.rs (permalink) เต็ม ๆ:
pub fn get_default_permissions(kind: WorkerKind) -> PermissionsOptions { match kind { WorkerKind::MainWorker | WorkerKind::EventsWorker => PermissionsOptions { allow_all: true, allow_env: Some(Default::default()), allow_net: Some(Default::default()), allow_ffi: Some(Default::default()), allow_read: Some(Default::default()), allow_run: Some(Default::default()), allow_sys: Some(Default::default()), allow_write: Some(Default::default()), allow_import: Some(Default::default()), ..Default::default() },
WorkerKind::UserWorker => PermissionsOptions { allow_all: false, allow_env: Some(Default::default()), allow_net: Some(Default::default()), allow_read: Some(Default::default()), allow_write: Some(Default::default()), allow_import: Some(Default::default()), allow_sys: Some(vec!["hostname".to_string()]), ..Default::default() }, }}นี่คือการแบ่งที่คอร์สนี้พูดถึงแบบไม่เป็นทางการมาตลอด: WorkerKind::MainWorker ได้ allow_all: true — สิ่งที่ parse request ของคุณ ตัดสินว่ามันเป็นของ function ไหน แล้ว proxy ไป ไม่มี sandbox เลยโดยการออกแบบ (มันคือโค้ดที่ Supabase เชื่อถือเอง ไม่ใช่ของคุณ) WorkerKind::UserWorker — function ของคุณเอง — ได้ allow_all: false และทุกตัวของ allow_read/allow_write/allow_ffi/allow_run ไม่ก็ไม่มีเลย (ถูกปฏิเสธเด็ดขาด อย่าง allow_ffi/allow_run ในนี้) ไม่ก็มีแต่ allow-list ว่างเปล่าโดยดีฟอลต์ หมายความว่า “read/write/env/net ทำได้ก็ต่อเมื่อมีอยู่ในรายการที่ struct นี้ไม่ได้เติมไว้ตรงนี้” — path ที่อนุญาตจริง (module directory ของ function เอง, /tmp, network แบบ loopback สำหรับเข้าถึง Postgres/Data API) ถูก grant แบบแคบ ๆ ที่อื่นในขั้นตอนสร้าง worker ไม่ใช่ในค่า default นี้
พิสูจน์: sandbox เป็นเรื่องจริง ไม่ใช่แค่คำแนะนำ
หัวข้อที่มีชื่อว่า “พิสูจน์: sandbox เป็นเรื่องจริง ไม่ใช่แค่คำแนะนำ”Function ใน namespace ของบทนี้เอง พยายามอ่านไฟล์ที่ไม่เกี่ยวอะไรเลยกับ module directory ของตัวเอง:
Deno.serve(async (req) => { const url = new URL(req.url); if (url.pathname.endsWith('/read-hosts')) { try { const text = await Deno.readTextFile('/etc/hosts'); return new Response(JSON.stringify({ ok: true, bytes: text.length }), { status: 200 }); } catch (err) { return new Response(JSON.stringify({ ok: false, name: (err as Error).name, message: (err as Error).message }), { status: 500 }); } } return new Response(JSON.stringify({ ok: true, now: Date.now() }), { status: 200 });});$ curl -s "$API_URL/functions/v1/int-edge-probe/read-hosts" -H "apikey: $ANON_KEY"{"ok":false,"name":"NotFound","message":"path not found: /etc/hosts: readfile '/etc/hosts': path not found: /etc/hosts: readfile '/etc/hosts': path not found: /etc/hosts"}
$ docker exec supabase_edge_runtime_probe cat /etc/hosts127.0.0.1 localhost::1 localhost ip6-localhost ip6-loopbackfe00:: ip6-localnetมีสองอย่างที่ควรพูดให้แม่นยำ ทั้งคู่พิสูจน์ตรง ๆ ด้านบน อย่างแรก docker exec เข้าไปใน container อ่าน /etc/hosts ได้ปกติ — ไฟล์นี้มีอยู่จริงบน filesystem นั้น มุมมอง filesystem แบบ sandbox ของ isolate เองต่างหากที่ปฏิเสธมัน ไม่ใช่ไฟล์หายไป อย่างที่สอง ชื่อ error คือ NotFound ไม่ใช่ PermissionDenied — isolate รายงาน path ที่อยู่นอก allowed set ราวกับมันไม่มีอยู่เลย แทนที่จะยืนยันว่า “มีอยู่จริง แต่คุณอ่านไม่ได้” ตรงกับจุดยืนด้าน security ของ Deno เองที่ไม่ยอมรั่วข้อมูลเรื่อง path ที่ script แบบ sandbox ไม่ควรรู้ตั้งแต่แรก และนี่คือความต่างจริง พิสูจน์ได้ จากสิ่งที่ developer ส่วนใหญ่คาดว่า error หน้าตาจะเป็น (ดู ความเข้าใจผิดที่พบบ่อย)
flowchart TD
http["HTTP request: /functions/v1/int-edge-probe"] --> kong["Kong gateway"]
kong --> main["Main worker (allow_all: true, unrestricted)"]
main -->|"parse path, resolve service_name"| pool{"Isolate already warm for this function?"}
pool -->|yes| warm["Reuse existing UserWorker isolate"]
pool -->|no| cold["EdgeRuntime.userWorkers.create({ servicePath, memoryLimitMb, workerTimeoutMs, ... })\nboot a NEW UserWorker isolate (allow_all: false)"]
cold --> exec["worker.fetch(req) inside the sandboxed isolate"]
warm --> exec
exec --> resp["Response back through the main worker"] พิสูจน์: cold เทียบ warm รวมถึง npm: import ที่หนัก
หัวข้อที่มีชื่อว่า “พิสูจน์: cold เทียบ warm รวมถึง npm: import ที่หนัก”What Are Edge Functions วัด Deno.serve hello-world เปล่า ๆ ไปแล้วได้ cold ~66ms, warm ~6-7ms บน Mac เครื่องเดียวกันนี้ วัด int-edge-probe ใหม่ (ก็เป็น Deno.serve เปล่า ๆ ไม่มี import) กับ process Edge Runtime ที่รันอยู่แล้ว ได้ช่องว่างที่เล็กกว่า — เตือนใจว่า “cold” หมายถึง request แรกของ isolate ตัวนั้น เสมอ ไม่ใช่ request แรกของ runtime ทั้งหมด เลย; ตัว process ของ main worker เอง และ V8 snapshot ภายในของ Deno เอง อุ่นอยู่แล้วจากการ serve function อื่นในเซสชันนี้:
$ curl -s -o /dev/null -w "first (cold): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe" -H "apikey: $ANON_KEY"first (cold): time_total=0.008365s$ curl -s -o /dev/null -w "second (warm): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe" -H "apikey: $ANON_KEY"second (warm): time_total=0.004279sประมาณ 2 เท่า ไม่ใช่ 10 เท่า — เพราะ module ของ isolate ตัวนี้เอง (ไฟล์เดียว ไม่มี import) แทบจะฟรีในการสร้างแม้จะ cold การเทียบที่น่าสนใจกว่าคือ function ที่ cold start มีงานให้ทำจริง ๆ — resolve และโหลด package แบบ npm::
const Schema = z.object({ n: z.number() });
Deno.serve(async (req) => { const parsed = Schema.safeParse({ n: 1 }); return new Response(JSON.stringify({ ok: parsed.success }), { status: 200 });});$ curl -s -o /dev/null -w "first (cold): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe-npm" -H "apikey: $ANON_KEY"first (cold): time_total=0.187833s$ curl -s -o /dev/null -w "second (warm): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe-npm" -H "apikey: $ANON_KEY"second (warm): time_total=0.003650sประมาณ 51 เท่า ไม่ใช่ ~2 เท่า — ต้นทุนจริง วัดได้ เฉพาะของ isolate นั้น: request แรกของ int-edge-probe-npm ต้อง resolve npm:[email protected] กับ npm registry, ดาวน์โหลด, แล้ว instantiate มันข้างใน isolate ใหม่ ทุก request warm หลังจากนั้นข้ามขั้นตอนทั้งหมดนี้ไปเลย นี่คือเหตุผลที่ pattern module-scope client ของบท edge-functions-and-the-database เองยิ่งสำคัญมากขึ้นสำหรับ function ที่มี dependency จริง ๆ — การ amortize ต้นทุนครั้งเดียวข้าม request warm หลาย ๆ ครั้งยิ่งคุ้มมากขึ้นยิ่ง import หนักขึ้น
พิสูจน์: module cache ที่การดาวน์โหลดพวกนี้ไปตกอยู่
หัวข้อที่มีชื่อว่า “พิสูจน์: module cache ที่การดาวน์โหลดพวกนี้ไปตกอยู่”$ docker exec supabase_edge_runtime_probe find /root/.cache/deno -maxdepth 1/root/.cache/deno/root/.cache/deno/node_analysis_cache_v2/root/.cache/deno/dep_analysis_cache_v2/root/.cache/deno/remote/root/.cache/deno/npm
$ docker exec supabase_edge_runtime_probe find /root/.cache/deno/npm/registry.npmjs.org/zod -maxdepth 2/root/.cache/deno/npm/registry.npmjs.org/zod/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/README.md/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/index.d.ts/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/package.json/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/lib[email protected] นั่งอยู่บนดิสก์จริง แกะแพ็กแล้ว ข้างใน Deno cache ของ container เอง — นี่แหละคือสิ่งที่ทำให้ isolate ตัวที่ สอง สำหรับ function เดียวกัน (หรือแม้แต่ isolate ใหม่เอี่ยมหลังจากตัวนี้ถูก retire ไป) ข้าม network resolution ไปได้เลย มีแค่ request แรกสุดที่ชน package+version ตัวนี้เป๊ะ ๆ บน container นี้เท่านั้นที่ต้องจ่ายต้นทุนดาวน์โหลด node_analysis_cache_v2/dep_analysis_cache_v2 (ทั้งคู่ backed ด้วย SQLite — ไฟล์ -wal/-shm ข้าง ๆ มันยืนยันแบบนั้น) คือ cache การวิเคราะห์ module-graph/Node-compat ของ Deno เอง ทำหน้าที่คล้ายกันอีกชั้นหนึ่ง: ข้ามการ parse dependency graph ของ module ซ้ำทุกครั้งที่ isolate cold
พิสูจน์: รูปร่างการ routing request ของ main worker เอง
หัวข้อที่มีชื่อว่า “พิสูจน์: รูปร่างการ routing request ของ main worker เอง”Supabase ไม่ได้เผยแพร่ script ของ main-service ที่ใช้ใน production เป๊ะ ๆ แต่ test fixture ของโปรเจกต์เอง (crates/base/test_cases/main/index.ts, permalink) — ที่ test suite ของโปรเจกต์เองใช้ขับ API EdgeRuntime.userWorkers ตัวเดียวกับที่ main worker จริงเรียก — โชว์รูปร่างเป๊ะ ๆ:
const path_parts = pathname.split("/");let service_name = path_parts[1];...const servicePath = `./test_cases/${service_name}`;...return await EdgeRuntime.userWorkers.create({ servicePath, memoryLimitMb, // proof above's isolate cost is bounded by this workerTimeoutMs, // the wall-clock limit docs describe, as a real parameter cpuTimeSoftLimitMs, cpuTimeHardLimitMs, noModuleCache, noNpm, envVars, context, staticPatterns: ["**/*.wasm"],});ส่วนแรกของ path (/int-edge-probe/read-hosts → int-edge-probe) คือ service_name แบบเดียวกับที่ curl ของบทนี้เองใช้ routing พึ่งพา และ EdgeRuntime.userWorkers.create คือการเรียกตัวจริงที่สร้าง “cold start” ที่บทนี้ (และบท what-are-edge-functions ก่อนหน้า) วัดไปแล้ว — workerTimeoutMs/cpuTimeSoftLimitMs/cpuTimeHardLimitMs/memoryLimitMb คือชื่อระดับกลไกเบื้องหลังลิมิตของ platform ที่ เอกสารของ Supabase เอง ระบุไว้ว่า 256MB memory, 2s CPU time ต่อ request, wall-clock 150s/400s (free/paid) และ response 546 WORKER_RESOURCE_LIMIT (เอกสาร troubleshooting) คือสิ่งที่ isolate คืนกลับมาเมื่อมันใช้ทรัพยากรใดทรัพยากรหนึ่งจนหมด: “Once an isolate uses 50% of any resource, it will finish the current request and then shut down. However, if that remaining request exhausts all CPU or memory before completion, the isolate will terminate immediately and return a 546 response.”
พิสูจน์: config policy เป็น Rust enum จริง ไม่ใช่แค่คอนเซปต์ในเอกสาร
หัวข้อที่มีชื่อว่า “พิสูจน์: config policy เป็น Rust enum จริง ไม่ใช่แค่คอนเซปต์ในเอกสาร”Config [edge_runtime] ของโปรเจกต์นี้เอง (tools/probe/supabase/config.toml) ตั้ง policy = "per_worker" พร้อม comment “enables hot reload during local development” เทียบกับค่า fallback "oneshot" สิ่งนี้ map ตรงไปยัง Rust type จริง ชื่อ SupervisorPolicy ใน crates/base/src/worker/pool.rs (permalink):
pub enum SupervisorPolicy { #[default] PerWorker, PerRequest { oneshot: bool, },}PerWorker คือสิ่งที่ทำให้พิสูจน์ warm-timing ของบทนี้เองด้านบนใช้ isolate ตัวเดียวกัน ซ้ำข้ามหลาย request ได้ (ตัวเลข “warm” ~4ms/~3.6ms); PerRequest { oneshot: true } — ค่า oneshot ของ CLI — จะ retire isolate หลังจากทุก request แทน แลกความเร็วจาก warm-reuse กับ sandbox ที่แยกสดใหม่ทุกครั้ง (มีประโยชน์ตอน debug function ที่อาจรั่ว state ข้าม request แต่ต้องจ่ายต้นทุน cold-start จริงทุก request)
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”🐛 หา bug ในโค้ดนี้
// AI-generated "read the request's client IP from a trusted proxy config file"Deno.serve(async (req) => {const proxyConfig = await Deno.readTextFile('/etc/nginx/nginx.conf');const trustedRanges = parseProxyConfig(proxyConfig);const clientIp = resolveClientIp(req, trustedRanges);return new Response(JSON.stringify({ clientIp }));});
function parseProxyConfig(text: string) { /* ... */ return []; }function resolveClientIp(req: Request, ranges: string[]) { /* ... */ return req.headers.get('x-forwarded-for'); }เฉลย
นี่คือ sandbox ที่พิสูจน์ด้านบนพอดี เจอจากอีกมุมหนึ่ง: isolate ของ Edge Function จริง ๆ มี allow_read ที่ไม่มี path นอก module directory ของตัวเองถูกอนุญาตโดยดีฟอลต์ (entry ของ WorkerKind::UserWorker ใน crates/base/src/runtime/permissions.rs อ้างเต็ม ๆ ด้านบน) — /etc/nginx/nginx.conf ไม่ได้เป็นส่วนหนึ่งของมุมมอง filesystem ของ isolate นั้นเลย ไม่ว่าไฟล์นั้นจะมีอยู่บน container จริงไหม หรือแม้แต่ nginx จะเป็นส่วนหนึ่งของ topology ของ stack นี้ไหมก็ตาม (มันไม่ใช่ — Kong คือ gateway ที่นี่) การเรียกนี้จะโยน NotFound แบบเดียวกับที่บทนี้พิสูจน์ไปด้านบน ไม่ใช่ “config file not found, did you mean…” ที่มีประโยชน์ และแน่นอนว่าไม่ใช่ trusted-range data ที่ logic ของ function สมมติว่าได้มา วิธีแก้เป็นเรื่องสถาปัตยกรรม ไม่ใช่ flag permission ให้พลิก: Edge Function ไม่ควรมีธุระอ่าน config ของ gateway/proxy จากดิสก์เลย — x-forwarded-for (หรือดีกว่านั้น header ที่ platform ตั้งให้และ function ของคุณเชื่อโดยไม่ต้องไปหา trust ใหม่จาก config file ที่มันไม่มีทางเห็น) คือ input เดียวที่มีอยู่จริงข้างใน sandbox และถ้าต้องการ logic IP-allowlisting จริง ๆ มันควรอยู่ที่ gateway (Kong) หรือใน Postgres RPC function ที่อ่านตารางที่ Edge Function ของคุณเรียกด้วย service_role ไม่ใช่ข้างใน isolate เอง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ลองนึกภาพ Edge Runtime เป็นพนักงานต้อนรับที่เชื่อถือได้คนเดียว (main worker, allow_all: true, process เดียว) ยืนอยู่หน้าห้องสัมภาษณ์กันเสียงชุดหนึ่ง (user worker isolate, allow_all: false) พนักงานต้อนรับไปไหนในตึกก็ได้และอ่านบัตรของผู้มาเยือนทุกคน (request path) เพื่อตัดสินว่าจะส่งไปห้องไหน — ใช้ห้องที่มีคนอยู่แล้วและอุ่นอยู่แล้วซ้ำถ้า policy อนุญาต (per_worker) หรือสร้างห้องใหม่ทั้งหมดถ้าไม่ (cold start พิสูจน์ด้านบนว่ามีต้นทุนจริง วัดเป็นมิลลิวินาทีได้ — สูงขึ้นมากถ้าผู้ใช้ห้องต้องการเฟอร์นิเจอร์ส่งมาจากข้างนอก คือ npm: import) เมื่อเข้าไปในห้องแล้ว ผู้มาเยือน (โค้ด function ของคุณ) แตะได้แค่สิ่งที่ห้องนั้นถูกสร้างมาให้มีอย่างชัดเจนเท่านั้น การขอสิ่งที่อยู่นอกนั้นไม่ได้รับ “ขอโทษ อันนั้นถูกจำกัด” แต่ได้ “สิ่งนั้นไม่มีอยู่ที่นี่” โดยการออกแบบ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ทางเลือกการออกแบบ | ข้อดี | ต้นทุน |
|---|---|---|
| แยก runtime สองแบบ: main worker ไม่ถูกจำกัด, user worker แบบ sandbox | โค้ดของคุณไม่มีทางแตะ routing layer หรือ isolate ของ tenant อื่นโดยไม่ตั้งใจ (หรือตั้งใจ) — พิสูจน์ด้านบนผ่านการปฏิเสธ filesystem จริงที่ bypass ไม่ได้ | process boundary กับ IPC hop เพิ่มขึ้น (การเรียก worker.fetch(req) ของ main worker เอง) ทุก request แม้จะ warm |
allow_all: false พร้อม allow-list ว่างเปล่าโดยดีฟอลต์ต่อประเภท permission | ความสามารถใหม่ (อ่าน path ใหม่, env var ใหม่) ต้องการ grant ที่ชัดเจน ตรวจสอบได้ ที่ไหนสักแห่งในขั้นตอนสร้าง worker ไม่ใช่ “ปิดโดยดีฟอลต์ ลืม lock down ง่าย” | Debug “ทำไม function ของฉันอ่านไฟล์นี้ไม่ได้” ต้องเข้าใจ sandbox model ที่บทนี้เพิ่งพิสูจน์ ไม่ใช่แค่ permission flag ทั่วไปของ Deno |
การใช้ isolate ซ้ำแบบ per_worker (ดีฟอลต์ของ stack นี้) | ความเร็ว warm 2 เท่าถึง 51 เท่าที่พิสูจน์ด้านบน ทุก request หลังจากตัวแรกของ function หนึ่ง ๆ | State คงอยู่จริงข้าม request ภายใน isolate เดียว (ตัวแปรระดับ module, connection ที่เปิดค้างไว้) — เป็นแหล่งบั๊กจริงถ้า function สมมติว่าทุก request เริ่มสดใหม่ทั้งหมด |
| Package แบบ npm: resolve และ cache ลงดิสก์ตั้งแต่ใช้ครั้งแรก | ทุก isolate หลังจากตัวแรก (แม้เป็น isolate ใหม่เอี่ยมของ function เดียวกัน) ข้าม network ไปเลย | request จริงแรกสุดของ function ที่เพิ่ง deploy ใหม่พร้อม dependency ใหม่ จ่ายต้นทุนจริง วัดได้ (188ms ในที่นี้) ที่ไม่เกี่ยวอะไรกับ logic ของ function เองเลย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “การละเมิด sandbox ของ Edge Function โยน error PermissionDenied” พิสูจน์ว่าผิดด้านบนด้วย error จริง ไม่แก้ไข: isolate รายงาน
NotFoundไม่ใช่PermissionDeniedสำหรับ path นอก allowed set — ตั้งใจให้แยกไม่ออกจาก “ไฟล์นี้ไม่มีอยู่จริง” - “ต้นทุน cold start ใกล้เคียงกันสำหรับทุก function ไม่ว่าจะ import อะไร” พิสูจน์ว่าผิดด้านบน:
Deno.serveเปล่า ๆ cold start ที่ ~8ms บน runtime process ที่อุ่นอยู่แล้ว ขณะที่ isolate class เดียวกันที่มีnpm:import หนึ่งตัว cold start ที่ ~188ms — ต่างกันประมาณ 23 เท่าในต้นทุน cold-start เต็ม ๆ ขับเคลื่อนโดยสิ่งที่ต้อง resolve และโหลดล้วน ๆ ไม่ใช่อะไรเกี่ยวกับ isolate โดยทั่วไป - “Main worker รันใน sandbox เดียวกับโค้ดผู้ใช้ แค่มีสิทธิ์มากกว่า” พิสูจน์ว่าผิดตรง ๆ จาก Rust source ด้านบน:
WorkerKind::MainWorkerได้allow_all: true— ไม่ถูกจำกัดแบบเด็ดขาด ไม่ใช่ “ถูกจำกัด แต่มี allow-list ยาวกว่า” แบบที่WorkerKind::UserWorkerเป็น - “docker exec เข้า Edge Runtime container พิสูจน์ได้ว่า function เข้าถึงอะไรได้บ้าง” พิสูจน์ว่าผิดด้านบน:
docker exec cat /etc/hostsสำเร็จง่าย ๆ (root filesystem ของ container เองไม่มี sandbox) ขณะที่ path เดียวกันเป๊ะจากข้างใน isolateUserWorkerล้มเหลว — container กับ isolate คือ security boundary สองแบบที่ต่างกัน และมีแค่ของ isolate เท่านั้นที่โค้ด function ของคุณรันอยู่ข้างในจริง ๆ