Image Transformations and the CDN
แนวคิดในประโยคเดียว
หัวข้อที่มีชื่อว่า “แนวคิดในประโยคเดียว”Supabase Storage ปรับขนาดและประมวลผลรูปภาพใหม่ได้ แบบ on-the-fly ตอน request จริงด้วย query parameter ธรรมดา หนุนหลังด้วย container imgproxy — แต่ local stack เฉพาะตัวนี้ปิด feature flag ของ container นั้นไว้ โดย default ซึ่งควรเข้าใจให้ชัดเจน เพราะมันคือ gate เดียวกันเป๊ะที่ hosted Supabase ใช้ปิดกั้นฟีเจอร์นี้ไว้หลังกำแพงเสียเงิน
การขอรูปภาพที่ถูกแปลง
หัวข้อที่มีชื่อว่า “การขอรูปภาพที่ถูกแปลง”แทนที่จะ upload photo.png หนึ่งไฟล์แล้ว upload photo-thumb.png, photo-medium.png แยกต่างหากไปเรื่อย ๆ คุณ upload รูปครั้งเดียวแล้วขอขนาดที่ต้องการตอนที่ serve มัน ทั้ง getPublicUrl และ createSignedUrl รับ option transform:
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const { data } = supabase.storage .from('rt-images') .getPublicUrl('user123/photo.png', { transform: { width: 100, height: 100, resize: 'cover', }, });
console.log(data.publicUrl);// http://127.0.0.1:55321/storage/v1/render/image/public/rt-images/user123/photo.png?width=100&height=100&resize=coverwidth กับ height กำหนดขนาดเป้าหมายเป็นพิกเซล resize ควบคุมว่ารูปจะพอดีกับขนาดนั้นอย่างไร: cover (default) เติมเต็มขนาดเป้าหมายแล้ว crop ส่วนเกิน contain ให้รูปทั้งหมดพอดีอยู่ในขนาดเป้าหมายโดยรักษา aspect ratio และ fill ยืดรูปให้พอดีขนาดเป้าหมายเป๊ะโดยไม่รักษา aspect ratio getPublicUrl สร้าง URL นี้ฝั่ง client โดยไม่มีการเรียก network เลย — มันแค่สร้าง string เท่านั้น — คำถามจริงคือเกิดอะไรขึ้นเมื่อ client เรียก URL นั้นจริง ๆ
พิสูจน์: local stack นี้ปิด image transformation ไว้ — และเพราะอะไรเป๊ะ ๆ
หัวข้อที่มีชื่อว่า “พิสูจน์: local stack นี้ปิด image transformation ไว้ — และเพราะอะไรเป๊ะ ๆ”PNG ขนาด 600×400 จริง (351,741 byte สร้างบน Mac เครื่องนี้ด้วย sips) upload เข้า bucket จริง แล้วเรียกผ่าน URL เดียวกันเป๊ะที่ transform option ของ getPublicUrl สร้างไว้ด้านบน:
--- transformed: width=100&height=100&resize=cover ---HTTP_STATUS:404{"statusCode":"404","error":"Not Found","message":"Route GET:/render/image/public/rt-images/.../photo.png?width=100&height=100&resize=cover not found","code":"InvalidRequest"}ไม่ใช่ความล้มเหลวของการแปลงรูป — route /render/image/... เองไม่มีอยู่บน Kong gateway ของ stack นี้เลย เช็ค environment ของ container storage เองยืนยันเหตุผล:
docker exec supabase_storage_probe env | grep -i "IMAGE_TRANSFORM\|IMGPROXY"ENABLE_IMAGE_TRANSFORMATION=falseIMAGE_TRANSFORMATION_ENABLED=falseIMGPROXY_URL=http://supabase_imgproxy_probe:5001IMGPROXY_URL ถูกตั้งค่าไว้ (storage-api รู้ว่า container imgproxy จะ อยู่ที่ไหน) แต่ ENABLE_IMAGE_TRANSFORMATION=false หมายความว่า storage-api ไม่เคยลงทะเบียน route /render/image ตั้งแต่แรก — และยืนยันด้วย docker ps -a ว่าไม่มี container supabase_imgproxy_probe อยู่เลยด้วยซ้ำ (มันจะถูกสร้างแบบ lazy ตอนใช้งานครั้งแรกที่ เปิด ไว้) สิ่งนี้สืบย้อนไปถึง supabase/config.toml ของ probe เองโดยตรง:
# tools/probe/supabase/config.toml (excerpt — NOT part of this lesson's migration; shown for reference only)[storage]enabled = truefile_size_limit = "50MiB"
# [storage.image_transformation]# enabled = trueblock [storage.image_transformation] มีอยู่แต่ comment ปิดไว้ — ค่า default ที่ CLI สร้างให้เองตอน scaffold นี่ไม่ใช่บั๊กใน probe ของคอร์สนี้ มันคือ default ที่ CLI บันทึกไว้เป็นทางการ และมันสะท้อน hosted Supabase เป๊ะ: image transformation เป็นฟีเจอร์ที่จำกัดไว้เฉพาะ Pro plan ขึ้นไป บนโปรเจกต์ hosted (supabase.com/docs/guides/storage/serving/image-transformations) ดังนั้นโปรเจกต์ local ใหม่ที่ default เป็น “ปิด” ก็ตรงกับโปรเจกต์ hosted free-tier ใหม่ที่ default เป็น “ปิด” เช่นกัน การเปิดมันใน local คือการแก้ config.toml หนึ่งบรรทัด (enabled = true ใต้ [storage.image_transformation]) แล้วตามด้วย supabase stop && supabase start — ไม่ได้ใช้งานจริงในบทนี้เพราะเป็นค่าที่ใช้ร่วมกันทั้ง probe stack ของคอร์สนี้ (การพิสูจน์ของบทอื่นต้องไม่ถูกรบกวนจากการ restart stack) ตามกติกาของ harness ของคอร์สนี้เอง
cache-control: สิ่งที่พิสูจน์ได้จริงบน local และความประหลาดใจที่พิสูจน์ได้
หัวข้อที่มีชื่อว่า “cache-control: สิ่งที่พิสูจน์ได้จริงบน local และความประหลาดใจที่พิสูจน์ได้”แยกจาก image transformation ทุกอ็อบเจกต์ของ Storage มี HTTP header Cache-Control จริง ควบคุมได้ตอน upload ผ่าน option cacheControl — และส่วนนี้ทำงานอยู่แล้ววันนี้ ไม่ว่า flag transform จะเป็นอย่างไร ค่า default (ไม่ส่ง cacheControl) เทียบกับค่าที่ระบุชัดเจน:
--- default upload, no cacheControl option ---cache-control: no-cache
--- upload with cacheControl: '3600' ---cache-control: 3600ทั้งคู่เป็น response header จริงจาก container supabase_storage_probe เดียวกัน อันที่สองควรหยุดคิดสักครู่: ตัวอย่างของเอกสารเองส่ง cacheControl: '3600' เป็น bare numeric string (cacheControl: '86400', // Cache for 24 hours — supabase.com/docs) และ string นั้นถูกเขียนเข้า header Cache-Control แบบคำต่อคำ — response จริงด้านบนคือ cache-control: 3600 เป๊ะ ไม่ใช่ cache-control: max-age=3600 ที่ถูกต้องตาม HTTP spec ตัวเลขเปล่า ๆ ไม่ใช่ directive Cache-Control ที่ถูกต้องตาม HTTP semantics ว่า CDN หรือเบราว์เซอร์ตัวใดตัวหนึ่งจะเคารพมัน เพิกเฉยมัน หรือ fallback ไปใช้ default เป็นพฤติกรรมที่ขึ้นกับ implementation ซึ่งคุณไม่ควรพึ่งพา ถ้าต้องการ header ที่ถูกต้องไม่คลุมเครือ ให้ตั้ง directive เต็มรูปแบบเองตรงที่ stack ของคุณอนุญาต แทนที่จะสมมติว่า cacheControl: '3600' เพียงอย่างเดียวจะได้ caching ที่ถูกต้องตามมาตรฐาน
CDN edge cache — Smart CDN, การ invalidate, และ trade-off ที่มันแทนที่
หัวข้อที่มีชื่อว่า “CDN edge cache — Smart CDN, การ invalidate, และ trade-off ที่มันแทนที่”บน hosted Supabase request แรกสำหรับไฟล์หนึ่งบวกชุด transform parameter หนึ่งทำงานจริง: Storage ถอดรหัส ปรับขนาด แล้วคืนผลลัพธ์; ผลลัพธ์ที่แปลงแล้วจะถูก cache ที่ CDN edge โดย key ด้วยไฟล์และ transform parameter ที่ตรงเป๊ะ เอกสารระบุชัดเจนเรื่องการ invalidate: “When Smart CDN is enabled, the asset is cached on the CDN for as long as possible… When a file is updated or deleted, the CDN cache is automatically invalidated… It can take up to 60 seconds for the CDN cache to be invalidated as the asset metadata has to propagate across all the data-centers around the globe” และ “Smart CDN caching works with all types of storage operations including signed URLs” (supabase.com/docs/guides/storage/cdn/smart-cdn) เรื่องนี้พิสูจน์ใน local ไม่ได้ (ไม่มี CDN edge ใน local Docker stack — ทุก request ที่นี่ไปตรงที่ container storage-api ตัวเดียว) ดังนั้นให้ถือว่าตัวเลข 60 วินาทีและข้อความ “ใช้ได้กับ signed URL ด้วย” เป็นข้อเท็จจริงจากเอกสาร ไม่ใช่สิ่งที่พิสูจน์ซ้ำได้ด้านบน
trade-off ที่สิ่งนี้แทนที่: การปรับขนาดรูปเองตอน upload แล้วเก็บ photo-thumb.png, photo-medium.png, photo-large.png หลีกเลี่ยงต้นทุนการแปลงตอนอ่านได้ แต่ต้องแลกด้วยการเก็บ N สำเนาของทุกรูปตลอดไป บังคับให้เลือกขนาดล่วงหน้า และต้องประมวลผลใหม่ทั้งหมดย้อนหลังถ้าต้องการขนาดที่ไม่ได้คาดไว้ การแปลงแบบ on-the-fly (เมื่อเปิดใช้) กลับด้านนั้น: latency เล็กน้อยตอน request แรก สำหรับขนาดหนึ่ง แลกกับการเก็บต้นฉบับแค่หนึ่งชุดต่อรูปและได้ทุกขนาดเมื่อไหร่ก็ได้
Signed vs public URL สำหรับรูปที่ถูกแปลง
หัวข้อที่มีชื่อว่า “Signed vs public URL สำหรับรูปที่ถูกแปลง”ทุกอย่างด้านบนทำงานร่วมกับกลไก signed-URL จากบทที่แล้วตามเอกสารพอดี — option transform ของ signed URL ทำงานแบบเดียวกับของ getPublicUrl และตามคำพูดของ Smart CDN ด้านบน edge cache ใช้กับ request แบบ signed-URL ด้วย ไม่ใช่แค่ public เท่านั้น ทางเลือกระหว่างสองแบบไม่เกี่ยวกับการแปลงรูปเลย: ใช้ getPublicUrl (จะมีหรือไม่มี transform ก็ได้) สำหรับ public bucket ที่ใครก็ตามที่มีลิงก์ควรเห็นรูปได้ — รูปสินค้า avatar สาธารณะ; ใช้ createSignedUrl (จะมีหรือไม่มี transform ก็ได้) สำหรับ private bucket ที่ต้องเช็คการเข้าถึงทุก request — thumbnail เอกสารส่วนตัวของผู้ใช้ รูปที่ต้องจ่ายเงินถึงดู — ยอมรับหน้าต่างเวลาหมดอายุที่ตายตัวเป็นต้นทุน ตามที่บท storage-buckets-and-policies ครอบคลุมไว้แล้ว
flowchart LR
original[("Original image: photo.png (351,741 bytes)")]
original --> req{"GET /render/image/...?width=100&height=100&resize=cover"}
req --> flag{"ENABLE_IMAGE_TRANSFORMATION?"}
flag -->|"false (this local stack)"| notfound["404: route not registered at all"]
flag -->|"true (hosted Pro+, or config.toml enabled)"| imgproxy["imgproxy decodes, resizes, re-encodes"]
imgproxy --> cdn["Cached at CDN edge, keyed by file + exact params"] อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI assistant ถูกขอให้ “serve thumbnail ขนาด 200x200 ของทุก avatar โดย cache ให้ถูกต้อง” แล้วเขียนโค้ดนี้มา:
🐛 หา bug ในโค้ดนี้
const { data } = supabase.storage.from('avatars').getPublicUrl(path, { transform: { width: 200, height: 200, resize: 'cover' },});
await supabase.storage.from('avatars').upload(path, file, {cacheControl: 3600,});
return data.publicUrl;เฉลย
บั๊กสองข้อที่แยกกัน ข้อแรก cacheControl: 3600 — ส่งเป็น number ไม่ใช่ string ที่ API คาดหวัง (cacheControl: '3600') — จะไม่ผ่านการเช็ค type ของ TypeScript กับ FileOptions ของ supabase-js (cacheControl?: string) ถ้าถูกจับตอน compile หรือ coerce/ทำงานผิดคาดแบบเงียบ ๆ ตอน runtime ถ้าไม่ถูกจับ ข้อสอง ที่ลึกซึ้งกว่า: แม้แก้เป็น string แล้ว cacheControl: '3600' ก็ ไม่ ได้ header Cache-Control: max-age=3600 ที่ถูกต้อง — พิสูจน์แล้วก่อนหน้านี้ในบทนี้ ค่านั้นถูกเขียนแบบคำต่อคำ ดังนั้น header จริงจะกลายเป็น Cache-Control: 3600 ที่ไม่ตรงมาตรฐาน “cache ให้ถูกต้อง” ต้องการทั้งการยอมรับความคลุมเครือนั้น (CDN หลายตัวยืดหยุ่น) หรือตั้ง header ผ่าน layer ของ stack ที่ให้เขียน directive เต็มรูปแบบได้ แยกกันไป — ไม่ใช่บั๊กในโค้ดนี้ แต่ควรเช็คก่อน deploy — getPublicUrl พร้อม transform สร้าง URL โดยสมมติว่า image transformation เปิดอยู่บนโปรเจกต์เป้าหมาย บนโปรเจกต์ใหม่ (local หรือ hosted free-tier) route นั้นจะ 404 ตามที่พิสูจน์แล้วด้านบนพอดี ดังนั้น “thumbnail” จะไม่ resolve เลยแบบเงียบ ๆ จนกว่าฟีเจอร์จะถูกเปิด
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Image transformation ไม่ใช่ฟีเจอร์ native ของ Storage — มันคือ Storage ที่มอบหมายงานไปให้ service แยกต่างหาก คือ imgproxy ที่ถูกกั้นไว้หลัง feature flag หนึ่งอันซึ่งทั้ง CLI local และ plan แบบ hosted ต่าง default เป็นปิด getPublicUrl/createSignedUrl พร้อม transform option ไม่เคยเรียก network เองเลย มันแค่สร้าง URL พร้อม query parameter และทุกอย่างที่น่าสนใจเกิดขึ้น (หรือไม่เกิดขึ้น ตามที่พิสูจน์แล้วด้านบน) ก็ต่อเมื่อ client เรียก URL นั้นจริง ๆ เท่านั้น cache-control ในทางตรงกันข้าม ไม่เกี่ยวอะไรกับ imgproxy เลย มันเป็นแค่ HTTP response header ธรรมดาที่ Storage echo กลับจากสิ่งที่คุณตั้งไว้ตอน upload ซึ่งเป็นเหตุผลที่มันทำงานเหมือนกันไม่ว่า image transformation จะเปิดหรือปิด และเป็นเหตุผลที่การส่งค่าที่ไม่ได้มาตรฐานให้มันจะได้ header ที่ไม่ได้มาตรฐานโดยไม่มีการตรวจสอบใด ๆ จับได้เลย
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- การแปลงแบบ on-the-fly (เมื่อเปิดใช้) เก็บต้นฉบับชุดเดียวแล้ว serve ได้ทุกขนาด — แลกมาด้วย feature flag/plan gate จริงที่ต้องเช็คก่อน พิสูจน์แล้วด้านบนเป็น 404 จริงพร้อมสาเหตุที่ชัดเจน ไม่ใช่ความล้มเหลวแบบเงียบ ๆ ที่ต้อง debug มืด ๆ
- การตั้ง
cacheControlเป็น option บรรทัดเดียวที่มีผลจริงต่อ response header — แลกมาด้วยการไม่มีการตรวจสอบ string ที่คุณส่งไปเลย พิสูจน์แล้วด้านบนว่าได้ header ที่ไม่ตรงมาตรฐานทางเทคนิคเมื่อส่งตัวเลขเปล่า ๆ แทนที่จะเป็น directive เต็มรูปแบบ - Smart CDN (เฉพาะ hosted) ลบต้นทุนการแปลงซ้ำหลัง request แรกต่อขนาด — แลกมาด้วยความเสี่ยง stale-cache สูงสุดประมาณ 60 วินาทีหลังการอัปเดต (จากเอกสาร) ซึ่งสำคัญถ้าแอปของคุณเขียนทับรูปในตำแหน่งเดิมแทนที่จะใช้ path ใหม่ต่อเวอร์ชัน
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ถ้า
transformoption ของgetPublicUrlcompile ผ่านและคืน URL มา รูปที่แปลงแล้วจะถูก serve” พิสูจน์แล้วด้านบน: URL ถูกสร้างโดยไม่มีการตรวจสอบเลยว่าโปรเจกต์เป้าหมายเปิด transformation ไว้หรือไม่ — ความล้มเหลวจะโผล่มาเป็น 404 จริงก็ต่อเมื่อ client เรียกมันจริง ๆ เท่านั้น - “404 บน transform URL หมายความว่าไฟล์หรือ path ผิด” พิสูจน์แล้วด้านบน: อ็อบเจกต์เดียวกันเป๊ะ serve ได้ถูกต้องที่ URL ธรรมดา (ไม่ transform) ของมัน — 404 นั้นคือ route
/render/image/...ที่ไม่มีอยู่โดยเฉพาะ สืบย้อนไปที่ENABLE_IMAGE_TRANSFORMATION=falseไม่ใช่ไฟล์หาย - “
cacheControl: '3600'ได้ headerCache-Controlแบบมาตรฐานmax-age=3600” พิสูจน์แล้วด้านบน: string ถูก echo แบบคำต่อคำเข้า header โดยไม่มีการจัดรูปแบบใหม่ — response จริงคือcache-control: 3600ซึ่งไม่ใช่ directiveCache-Controlที่ถูกต้องด้วยตัวเอง - “CDN caching สำหรับรูปที่แปลงแล้วเป็นสิ่งที่คุณต้องตั้งค่าเอง” ตามเอกสารที่อ้างอิงด้านบน Smart CDN caching เป็นอัตโนมัติเมื่อเปิดใช้ — สิ่งเดียวที่คุณควบคุมได้คือค่า
cacheControlของอ็อบเจกต์เองตอน upload ไม่ใช่ขั้นตอนตั้งค่า CDN แยกต่างหาก